diff --git a/assets/images/envelope-open-star.svg b/assets/images/envelope-open-star.svg new file mode 100644 index 0000000000000..74652c126f5fe --- /dev/null +++ b/assets/images/envelope-open-star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 3088353162f84..bd9ff66b349a6 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -49,6 +49,7 @@ import Download from '@assets/images/download.svg'; import DragAndDrop from '@assets/images/drag-and-drop.svg'; import DragHandles from '@assets/images/drag-handles.svg'; import Emoji from '@assets/images/emoji.svg'; +import EnvelopeOpenStar from '@assets/images/envelope-open-star.svg'; import EReceiptIcon from '@assets/images/eReceiptIcon.svg'; import Exclamation from '@assets/images/exclamation.svg'; import Exit from '@assets/images/exit.svg'; @@ -215,6 +216,7 @@ export { DragHandles, EReceiptIcon, Emoji, + EnvelopeOpenStar, ExpenseCopy, Exclamation, Exit, diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 30971f4236f1f..3bd6b211b1553 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -81,6 +81,7 @@ import Emoji from '@assets/images/emoji.svg'; import Lightbulb from '@assets/images/emojiCategoryIcons/light-bulb.svg'; import EmptyStateRoutePending from '@assets/images/emptystate__routepending.svg'; import EmptyStateSpyPigeon from '@assets/images/emptystate__spy-pigeon.svg'; +import EnvelopeOpenStar from '@assets/images/envelope-open-star.svg'; import EReceiptIcon from '@assets/images/eReceiptIcon.svg'; import Exclamation from '@assets/images/exclamation.svg'; import Exit from '@assets/images/exit.svg'; @@ -319,6 +320,7 @@ const Expensicons = { DragHandles, EReceiptIcon, Emoji, + EnvelopeOpenStar, EmptyStateRoutePending, ExpenseCopy, Exclamation, diff --git a/src/languages/de.ts b/src/languages/de.ts index 479532f059cd6..56eef0b683adf 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -986,6 +986,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Verbindung der persönlichen Karte ${cardName} reparieren` : 'Verbindung der persönlichen Karte reparieren'), subtitle: 'Wallet', }, + validateAccount: {title: 'Bestätigen Sie Ihr Konto, um Expensify weiter zu verwenden', subtitle: 'Konto', cta: 'Bestätigen'}, }, assignedCards: 'Ihre Expensify Karten', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} verbleibend`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 57d99ff580a70..9e1b3c985c71b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1019,6 +1019,11 @@ const translations = { subtitle: 'Expensify Card', cta: 'Review', }, + validateAccount: { + title: 'Validate your account to continue using Expensify', + subtitle: 'Account', + cta: 'Validate', + }, }, assignedCards: 'Your Expensify Cards', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} remaining`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 8175dfea22dc0..28c768351a01a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -891,6 +891,11 @@ const translations: TranslationDeepObject = { subtitle: 'Tarjeta Expensify', cta: 'Revisar', }, + validateAccount: { + title: 'Valida tu cuenta para continuar usando Expensify', + subtitle: 'Cuenta', + cta: 'Validar', + }, }, assignedCards: 'Tus tarjetas Expensify', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} restantes`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 3c5ff04c4e890..e3141cb9aa261 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -989,6 +989,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Réparer la connexion de la carte personnelle ${cardName}` : 'Corriger la connexion de la carte personnelle'), subtitle: 'Portefeuille', }, + validateAccount: {title: 'Validez votre compte pour continuer à utiliser Expensify', subtitle: 'Compte', cta: 'Valider'}, }, assignedCards: 'Vos cartes Expensify', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} restant`, diff --git a/src/languages/it.ts b/src/languages/it.ts index a14b9bf77a55c..2814a3f55957f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -986,6 +986,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Correggi la connessione della carta personale ${cardName}` : 'Correggi connessione carta personale'), subtitle: 'Portafoglio', }, + validateAccount: {title: 'Conferma il tuo account per continuare a usare Expensify', subtitle: 'Account', cta: 'Conferma'}, }, assignedCards: 'Le tue Carte Expensify', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} rimanenti`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d5bdfadc8efe5..1fb0fb5313e16 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -978,6 +978,7 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会計`, }, fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, + validateAccount: {title: 'Expensify を引き続きご利用いただくには、アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, }, assignedCards: 'お客様の Expensify カード', assignedCardsRemaining: ({amount}: {amount: string}) => `残額:${amount}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d43530ba86d30..5bee400ffe6f5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -985,6 +985,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Verbinding van persoonlijke kaart ${cardName} herstellen` : 'Verbinding persoonlijke kaart herstellen'), subtitle: 'Portemonnee', }, + validateAccount: {title: 'Valideer je account om Expensify te blijven gebruiken', subtitle: 'Account', cta: 'Valideren'}, }, assignedCards: 'Je Expensify Kaarten', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} resterend`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9afc913a06cc5..3bea0ab6b5ffd 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -986,6 +986,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Napraw połączenie z prywatną kartą ${cardName}` : 'Napraw połączenie karty prywatnej'), subtitle: 'Portfel', }, + validateAccount: {title: 'Zweryfikuj swoje konto, aby dalej korzystać z Expensify', subtitle: 'Konto', cta: 'Zatwierdź'}, }, assignedCards: 'Twoje Karty Expensify', assignedCardsRemaining: ({amount}: {amount: string}) => `Pozostało ${amount}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index dc69a1bbed4e9..e2c3fb53a3f5f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -984,6 +984,7 @@ const translations: TranslationDeepObject = { title: ({cardName}: {cardName?: string}) => (cardName ? `Corrigir conexão do cartão pessoal ${cardName}` : 'Corrigir conexão do cartão pessoal'), subtitle: 'Carteira', }, + validateAccount: {title: 'Valide sua conta para continuar usando o Expensify', subtitle: 'Conta', cta: 'Validar'}, }, assignedCards: 'Seus Cartões Expensify', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} restante`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 4c966a9baaf5d..96415f4fa273d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -964,6 +964,7 @@ const translations: TranslationDeepObject = { defaultSubtitle: '工作区', subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, + validateAccount: {title: '验证您的账户以继续使用 Expensify', subtitle: '账户', cta: '验证'}, }, assignedCards: '你的 Expensify 卡', assignedCardsRemaining: ({amount}: {amount: string}) => `剩余 ${amount}`, diff --git a/src/pages/home/TimeSensitiveSection/index.tsx b/src/pages/home/TimeSensitiveSection/index.tsx index ea9eb87903e2a..537e583d0aa08 100644 --- a/src/pages/home/TimeSensitiveSection/index.tsx +++ b/src/pages/home/TimeSensitiveSection/index.tsx @@ -1,15 +1,19 @@ +import {isUserValidatedSelector} from '@selectors/Account'; import {activeAdminPoliciesSelector} from '@selectors/Policy'; +import {emailSelector} from '@selectors/Session'; import React, {useCallback} from 'react'; import {View} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; import WidgetContainer from '@components/WidgetContainer'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {hasSynchronizationErrorMessage, isConnectionInProgress} from '@libs/actions/connections'; +import {isCurrentUserValidated} from '@libs/UserUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import type {ConnectionName, PolicyConnectionName} from '@src/types/onyx/Policy'; @@ -24,6 +28,7 @@ import FixPersonalCardConnection from './items/FixPersonalCardConnection'; import Offer25off from './items/Offer25off'; import Offer50off from './items/Offer50off'; import ReviewCardFraud from './items/ReviewCardFraud'; +import ValidateAccount from './items/ValidateAccount'; type BrokenAccountingConnection = { /** The policy ID associated with this connection */ @@ -57,6 +62,7 @@ function TimeSensitiveSection() { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {login} = useCurrentUserPersonalDetails(); + const isAnonymous = useIsAnonymousUser(); // Use custom hooks for offers and cards (Release 3) const {shouldShow50off, shouldShow25off, shouldShowAddPaymentCard, firstDayFreeTrial, discountInfo} = useTimeSensitiveOffers(); @@ -64,8 +70,15 @@ function TimeSensitiveSection() { // Selector for filtering admin policies (Release 4) const adminPoliciesSelectorWrapper = useCallback((policies: OnyxCollection) => activeAdminPoliciesSelector(policies, login ?? ''), [login]); - const [adminPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: adminPoliciesSelectorWrapper}); + const [adminPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: adminPoliciesSelectorWrapper, + }); const [connectionSyncProgress] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS); + const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, { + selector: isUserValidatedSelector, + }); + const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); + const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); // Get card feed errors for company card connections (Release 4) const cardFeedErrors = useCardFeedErrors(); @@ -132,10 +145,13 @@ function TimeSensitiveSection() { const hasBrokenCompanyCards = brokenCompanyCardConnections.length > 0; const hasBrokenPersonalCards = brokenPersonalCardConnections.length > 0; const hasBrokenAccountingConnections = brokenAccountingConnections.length > 0; + const isCurrentLoginValidated = isCurrentUserValidated(loginList, sessionEmail ?? login); + const shouldShowValidateAccount = isUserValidated === false && !isAnonymous && !isCurrentLoginValidated; // This guard must exactly match the conditions used to render each widget below. // If a widget has additional conditions in the render (e.g. && !!discountInfo), those // must be reflected here to avoid showing an empty "Time sensitive" section. const hasAnyTimeSensitiveContent = + shouldShowValidateAccount || shouldShowReviewCardFraud || shouldShowAddPaymentCard || shouldShow50off || @@ -151,18 +167,22 @@ function TimeSensitiveSection() { } // Priority order: - // 1. Potential card fraud - // 2. Add payment card (trial ended, no payment card) - // 3. Broken bank connections (company cards) - // 4. Broken bank connections (personal cards) - // 5. Broken accounting connections - // 6. Early adoption discount (50% or 25%) - // 7. Expensify card shipping - // 8. Expensify card activation + // 1. Validate account + // 2. Potential card fraud + // 3. Add payment card (trial ended, no payment card) + // 4. Broken bank connections (company cards) + // 5. Broken bank connections (personal cards) + // 6. Broken accounting connections + // 7. Early adoption discount (50% or 25%) + // 8. Expensify card shipping + // 9. Expensify card activation return ( - {/* Priority 1: Card fraud alerts */} + {/* Priority 1: Validate account */} + {shouldShowValidateAccount && } + + {/* Priority 2: Card fraud alerts */} {shouldShowReviewCardFraud && cardsWithFraud.map((card) => { if (!card.nameValuePairs?.possibleFraud) { @@ -176,9 +196,9 @@ function TimeSensitiveSection() { ); })} - {/* Priority 2: Add payment card (trial ended, no payment card) */} + {/* Priority 3: Add payment card (trial ended, no payment card) */} {shouldShowAddPaymentCard && } - {/* Priority 3: Broken company card connections */} + {/* Priority 4: Broken company card connections */} {brokenCompanyCardConnections.map((connection) => { const card = cardFeedErrors.cardsWithBrokenFeedConnection[connection.cardID]; if (!card) { @@ -194,7 +214,7 @@ function TimeSensitiveSection() { ); })} - {/* Priority 4: Broken personal card connections */} + {/* Priority 5: Broken personal card connections */} {brokenPersonalCardConnections.map((connection) => { const card = cardFeedErrors.personalCardsWithBrokenConnection[connection.cardID]; if (!card) { @@ -208,7 +228,7 @@ function TimeSensitiveSection() { ); })} - {/* Priority 5: Broken accounting connections */} + {/* Priority 6: Broken accounting connections */} {brokenAccountingConnections.map((connection) => ( ))} - {/* Priority 6: Early adoption discount offers */} + {/* Priority 7: Early adoption discount offers */} {shouldShow50off && } {shouldShow25off && !!discountInfo && } - {/* Priority 7: Expensify card shipping */} + {/* Priority 8: Expensify card shipping */} {shouldShowAddShippingAddress && cardsNeedingShippingAddress.map((card) => ( ))} - {/* Priority 8: Expensify card activation */} + {/* Priority 9: Expensify card activation */} {shouldShowActivateCard && cardsNeedingActivation.map((card) => ( Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_VERIFY_ACCOUNT.getRoute())} + buttonProps={{success: true}} + /> + ); +} + +export default ValidateAccount; diff --git a/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx new file mode 100644 index 0000000000000..a1a5407636f1d --- /dev/null +++ b/tests/unit/pages/home/TimeSensitiveSection/ValidateAccountTest.tsx @@ -0,0 +1,134 @@ +import {render, screen} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import TimeSensitiveSection from '@src/pages/home/TimeSensitiveSection'; +import useTimeSensitiveOffers from '@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveOffers'; +import waitForBatchedUpdates from '../../../../utils/waitForBatchedUpdates'; + +jest.mock('@libs/Navigation/Navigation'); + +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: jest.fn((key: string) => key)}))); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + EnvelopeOpenStar: () => null, + })), +})); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveOffers', () => + jest.fn(() => ({ + shouldShow50off: false, + shouldShow25off: false, + shouldShowAddPaymentCard: false, + firstDayFreeTrial: undefined, + discountInfo: undefined, + })), +); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards', () => + jest.fn(() => ({ + shouldShowAddShippingAddress: false, + shouldShowActivateCard: false, + shouldShowReviewCardFraud: false, + cardsNeedingShippingAddress: [], + cardsNeedingActivation: [], + cardsWithFraud: [], + })), +); + +jest.mock('@hooks/useCardFeedErrors', () => + jest.fn(() => ({ + cardsWithBrokenFeedConnection: {}, + personalCardsWithBrokenConnection: {}, + })), +); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'test@example.com'}))); + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); + +const renderTimeSensitiveSection = () => + render( + + + , + ); + +describe('TimeSensitiveSection - ValidateAccount', () => { + const mockedUseTimeSensitiveOffers = jest.mocked(useTimeSensitiveOffers); + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockedUseTimeSensitiveOffers.mockReturnValue({ + shouldShow50off: false, + shouldShow25off: false, + shouldShowAddPaymentCard: false, + firstDayFreeTrial: undefined, + discountInfo: null, + }); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('shows ValidateAccount widget when account is not validated', async () => { + await Onyx.set(ONYXKEYS.ACCOUNT, {validated: false}); + await Onyx.set(ONYXKEYS.SESSION, {authTokenType: CONST.AUTH_TOKEN_TYPES.SUPPORT}); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.getByText('homePage.timeSensitiveSection.validateAccount.title')).toBeTruthy(); + }); + + it('hides ValidateAccount for anonymous users while keeping time sensitive section visible', async () => { + mockedUseTimeSensitiveOffers.mockReturnValue({ + shouldShow50off: false, + shouldShow25off: false, + shouldShowAddPaymentCard: true, + firstDayFreeTrial: undefined, + discountInfo: null, + }); + + await Onyx.set(ONYXKEYS.ACCOUNT, {validated: false}); + await Onyx.set(ONYXKEYS.SESSION, {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS}); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.getByText('homePage.timeSensitiveSection.title')).toBeTruthy(); + expect(screen.getByText('homePage.timeSensitiveSection.addPaymentCard.title')).toBeTruthy(); + expect(screen.queryByText('homePage.timeSensitiveSection.validateAccount.title')).toBeNull(); + }); + + it('hides ValidateAccount when current login is already validated in login list', async () => { + const validatedEmail = 'test@example.com'; + + mockedUseTimeSensitiveOffers.mockReturnValue({ + shouldShow50off: false, + shouldShow25off: false, + shouldShowAddPaymentCard: true, + firstDayFreeTrial: undefined, + discountInfo: null, + }); + + await Onyx.set(ONYXKEYS.ACCOUNT, {validated: false}); + await Onyx.set(ONYXKEYS.SESSION, {authTokenType: CONST.AUTH_TOKEN_TYPES.SUPPORT, email: validatedEmail}); + await Onyx.set(ONYXKEYS.LOGIN_LIST, { + [validatedEmail]: { + validatedDate: '2026-03-18 00:00:00.000', + }, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.getByText('homePage.timeSensitiveSection.title')).toBeTruthy(); + expect(screen.getByText('homePage.timeSensitiveSection.addPaymentCard.title')).toBeTruthy(); + expect(screen.queryByText('homePage.timeSensitiveSection.validateAccount.title')).toBeNull(); + }); +});