From 8a59e66e781a624165e3d9f3a07ac03b3fffac4c Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Fri, 30 Jan 2026 12:30:18 -0800 Subject: [PATCH 1/9] Remove the unused filter method --- src/selectors/Card.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/selectors/Card.ts b/src/selectors/Card.ts index f8c1cba43866b..ea83574d4a5d5 100644 --- a/src/selectors/Card.ts +++ b/src/selectors/Card.ts @@ -28,14 +28,6 @@ const filterOutPersonalCards = (cards: OnyxEntry): CardList => { return filterObject(cards ?? {}, (key, card) => !isPersonalCard(card)); }; -/** - * Filter to keep only personal cards from the card list. - * Personal cards have fundID === '0' or no fundID. - */ -const filterPersonalCards = (cards: OnyxEntry): CardList => { - return filterObject(cards ?? {}, (key, card) => isPersonalCard(card)); -}; - /** * Selects the Expensify Card feed from the card list and returns the first one. */ @@ -49,4 +41,4 @@ const defaultExpensifyCardSelector = (allCards: OnyxEntry (cardList: OnyxEntry) => cardList?.[cardID]; -export {filterCardsHiddenFromSearch, filterOutPersonalCards, filterPersonalCards, defaultExpensifyCardSelector, cardByIdSelector}; +export {filterCardsHiddenFromSearch, filterOutPersonalCards, defaultExpensifyCardSelector, cardByIdSelector}; From ab207a5f24a9fe7d239e45f1a1c1b2457e6636bc Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Fri, 30 Jan 2026 13:17:20 -0800 Subject: [PATCH 2/9] Use selector and add unit tests --- .../hooks/useTimeSensitiveCards.ts | 35 +-- src/selectors/Card.ts | 46 +++- .../unit/hooks/useTimeSensitiveCards.test.ts | 171 ++++++++++++++ .../unit/hooks/useTimeSensitiveOffers.test.ts | 213 ++++++++++++++++++ tests/unit/selectors/CardTest.ts | 186 ++++++++++++++- 5 files changed, 619 insertions(+), 32 deletions(-) create mode 100644 tests/unit/hooks/useTimeSensitiveCards.test.ts create mode 100644 tests/unit/hooks/useTimeSensitiveOffers.test.ts diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts index 909683ad61ba4..62ed4c8777c34 100644 --- a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts @@ -1,36 +1,15 @@ -import {useMemo} from 'react'; import useOnyx from '@hooks/useOnyx'; -import {isCard, isCardPendingActivate, isCardPendingIssue} from '@libs/CardUtils'; -import CONST from '@src/CONST'; +import {timeSensitiveCardsSelector} from '@selectors/Card'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Card} from '@src/types/onyx'; function useTimeSensitiveCards() { - const [cardList] = useOnyx(ONYXKEYS.CARD_LIST, {canBeMissing: true}); + const [timeSensitiveCards] = useOnyx(ONYXKEYS.CARD_LIST, { + canBeMissing: true, + selector: timeSensitiveCardsSelector, + }); - const {cardsNeedingShippingAddress, cardsNeedingActivation} = useMemo<{cardsNeedingShippingAddress: Card[]; cardsNeedingActivation: Card[]}>(() => { - const cards = Object.values(cardList ?? {}).filter(isCard); - const isPhysicalExpensifyCard = (card: Card) => card.bank === CONST.EXPENSIFY_CARD.BANK && !card.nameValuePairs?.isVirtual; - - return cards.reduce<{cardsNeedingShippingAddress: Card[]; cardsNeedingActivation: Card[]}>( - (acc, card) => { - if (!isPhysicalExpensifyCard(card)) { - return acc; - } - - if (isCardPendingIssue(card)) { - acc.cardsNeedingShippingAddress.push(card); - } - - if (isCardPendingActivate(card)) { - acc.cardsNeedingActivation.push(card); - } - - return acc; - }, - {cardsNeedingShippingAddress: [], cardsNeedingActivation: []}, - ); - }, [cardList]); + const cardsNeedingShippingAddress = timeSensitiveCards?.cardsNeedingShippingAddress ?? []; + const cardsNeedingActivation = timeSensitiveCards?.cardsNeedingActivation ?? []; const shouldShowAddShippingAddress = cardsNeedingShippingAddress.length > 0; const shouldShowActivateCard = cardsNeedingActivation.length > 0; diff --git a/src/selectors/Card.ts b/src/selectors/Card.ts index ea83574d4a5d5..b60a5b804bab3 100644 --- a/src/selectors/Card.ts +++ b/src/selectors/Card.ts @@ -1,8 +1,9 @@ import type {OnyxEntry} from 'react-native-onyx'; import {getCardFeedsForDisplay} from '@libs/CardFeedUtils'; -import {isCard, isCardHiddenFromSearch, isPersonalCard} from '@libs/CardUtils'; +import {isCard, isCardHiddenFromSearch, isCardPendingActivate, isCardPendingIssue, isPersonalCard} from '@libs/CardUtils'; import {filterObject} from '@libs/ObjectUtils'; -import type {CardList, NonPersonalAndWorkspaceCardListDerivedValue} from '@src/types/onyx'; +import CONST from '@src/CONST'; +import type {Card, CardList, NonPersonalAndWorkspaceCardListDerivedValue} from '@src/types/onyx'; /** * Filter out cards that are hidden from search. @@ -41,4 +42,43 @@ const defaultExpensifyCardSelector = (allCards: OnyxEntry (cardList: OnyxEntry) => cardList?.[cardID]; -export {filterCardsHiddenFromSearch, filterOutPersonalCards, defaultExpensifyCardSelector, cardByIdSelector}; +type TimeSensitiveCardsResult = { + cardsNeedingShippingAddress: Card[]; + cardsNeedingActivation: Card[]; +}; + +/** + * Selector that filters cards to find physical Expensify cards that need shipping address or activation. + * Returns two arrays: cards pending issue (need shipping) and cards pending activation. + */ +const timeSensitiveCardsSelector = (cards: OnyxEntry): TimeSensitiveCardsResult => { + const result: TimeSensitiveCardsResult = { + cardsNeedingShippingAddress: [], + cardsNeedingActivation: [], + }; + + for (const card of Object.values(cards ?? {})) { + if (!isCard(card)) { + continue; + } + + // Only consider physical Expensify cards + const isPhysicalExpensifyCard = card.bank === CONST.EXPENSIFY_CARD.BANK && !card.nameValuePairs?.isVirtual; + if (!isPhysicalExpensifyCard) { + continue; + } + + if (isCardPendingIssue(card)) { + result.cardsNeedingShippingAddress.push(card); + } + + if (isCardPendingActivate(card)) { + result.cardsNeedingActivation.push(card); + } + } + + return result; +}; + +export {filterCardsHiddenFromSearch, filterOutPersonalCards, defaultExpensifyCardSelector, cardByIdSelector, timeSensitiveCardsSelector}; +export type {TimeSensitiveCardsResult}; diff --git a/tests/unit/hooks/useTimeSensitiveCards.test.ts b/tests/unit/hooks/useTimeSensitiveCards.test.ts new file mode 100644 index 0000000000000..b4707afcd6f7b --- /dev/null +++ b/tests/unit/hooks/useTimeSensitiveCards.test.ts @@ -0,0 +1,171 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useTimeSensitiveCards from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, CardList} from '@src/types/onyx'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +import {createRandomExpensifyCard} from '../../utils/collections/card'; + +describe('useTimeSensitiveCards', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('should return empty arrays when no cards exist', () => { + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsNeedingShippingAddress).toEqual([]); + expect(result.current.cardsNeedingActivation).toEqual([]); + expect(result.current.shouldShowAddShippingAddress).toBe(false); + expect(result.current.shouldShowActivateCard).toBe(false); + }); + + it('should return empty arrays when no cards need action', async () => { + const openCard = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}); + const cardList: CardList = {'1': openCard}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsNeedingShippingAddress).toEqual([]); + expect(result.current.cardsNeedingActivation).toEqual([]); + expect(result.current.shouldShowAddShippingAddress).toBe(false); + expect(result.current.shouldShowActivateCard).toBe(false); + }); + + it('should identify cards needing shipping address and set shouldShowAddShippingAddress to true', async () => { + const cardNeedingShipping = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + const cardList: CardList = {'1': cardNeedingShipping}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsNeedingShippingAddress).toHaveLength(1); + expect(result.current.cardsNeedingShippingAddress[0].cardID).toBe(1); + expect(result.current.shouldShowAddShippingAddress).toBe(true); + expect(result.current.shouldShowActivateCard).toBe(false); + }); + + it('should identify cards needing activation and set shouldShowActivateCard to true', async () => { + const cardNeedingActivation = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + const cardList: CardList = {'1': cardNeedingActivation}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsNeedingActivation).toHaveLength(1); + expect(result.current.cardsNeedingActivation[0].cardID).toBe(1); + expect(result.current.shouldShowActivateCard).toBe(true); + expect(result.current.shouldShowAddShippingAddress).toBe(false); + }); + + it('should handle multiple cards needing different actions', async () => { + const cardNeedingShipping = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + const cardNeedingActivation = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + const openCard = createRandomExpensifyCard(3, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}); + + const cardList: CardList = { + '1': cardNeedingShipping, + '2': cardNeedingActivation, + '3': openCard, + }; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsNeedingShippingAddress).toHaveLength(1); + expect(result.current.cardsNeedingActivation).toHaveLength(1); + expect(result.current.shouldShowAddShippingAddress).toBe(true); + expect(result.current.shouldShowActivateCard).toBe(true); + }); + + it('should exclude virtual cards from time-sensitive results', async () => { + const virtualCard: Card = { + ...createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}), + nameValuePairs: {isVirtual: true} as Card['nameValuePairs'], + }; + const physicalCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + + const cardList: CardList = { + '1': virtualCard, + '2': physicalCard, + }; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + // Only physical card should be included + expect(result.current.cardsNeedingActivation).toHaveLength(1); + expect(result.current.cardsNeedingActivation[0].cardID).toBe(2); + }); + + it('should exclude non-Expensify cards from time-sensitive results', async () => { + // Company card with pending state + const companyCard: Card = { + cardID: 1, + bank: 'vcf', + state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED, + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + lastUpdated: '2024-01-01', + } as Card; + + const expensifyCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + + const cardList: CardList = { + '1': companyCard, + '2': expensifyCard, + }; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + // Only Expensify card should be included + expect(result.current.cardsNeedingShippingAddress).toHaveLength(1); + expect(result.current.cardsNeedingShippingAddress[0].cardID).toBe(2); + }); + + it('should update when card list changes', async () => { + const openCard = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}); + const cardList: CardList = {'1': openCard}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result, rerender} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.shouldShowActivateCard).toBe(false); + + // Add a card needing activation + const cardNeedingActivation = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + await Onyx.merge(ONYXKEYS.CARD_LIST, {'2': cardNeedingActivation}); + await waitForBatchedUpdates(); + + rerender({}); + + expect(result.current.cardsNeedingActivation).toHaveLength(1); + expect(result.current.shouldShowActivateCard).toBe(true); + }); +}); diff --git a/tests/unit/hooks/useTimeSensitiveOffers.test.ts b/tests/unit/hooks/useTimeSensitiveOffers.test.ts new file mode 100644 index 0000000000000..824c1fd465d66 --- /dev/null +++ b/tests/unit/hooks/useTimeSensitiveOffers.test.ts @@ -0,0 +1,213 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useTimeSensitiveOffers from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveOffers'; +import ONYXKEYS from '@src/ONYXKEYS'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +// Mock the hooks and utils that useTimeSensitiveOffers depends on +jest.mock('@hooks/useHasTeam2025Pricing', () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +jest.mock('@hooks/useSubscriptionPlan', () => ({ + __esModule: true, + default: jest.fn(() => 'corporate'), +})); + +jest.mock('@libs/SubscriptionUtils', () => ({ + shouldShowDiscountBanner: jest.fn(() => false), + getEarlyDiscountInfo: jest.fn(() => null), +})); + +// Import mocks after they're defined +import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing'; +import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; +import {getEarlyDiscountInfo, shouldShowDiscountBanner} from '@libs/SubscriptionUtils'; + +const mockedUseHasTeam2025Pricing = useHasTeam2025Pricing as jest.Mock; +const mockedUseSubscriptionPlan = useSubscriptionPlan as jest.Mock; +const mockedShouldShowDiscountBanner = shouldShowDiscountBanner as jest.Mock; +const mockedGetEarlyDiscountInfo = getEarlyDiscountInfo as jest.Mock; + +describe('useTimeSensitiveOffers', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + jest.clearAllMocks(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + describe('when discount banner should not be shown', () => { + it('should return shouldShow50off and shouldShow25off as false when shouldShowDiscountBanner returns false', () => { + mockedShouldShowDiscountBanner.mockReturnValue(false); + mockedGetEarlyDiscountInfo.mockReturnValue({discountType: 50, days: 5}); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.shouldShow50off).toBe(false); + expect(result.current.shouldShow25off).toBe(false); + }); + + it('should return shouldShow50off and shouldShow25off as false when discountInfo is null', () => { + mockedShouldShowDiscountBanner.mockReturnValue(true); + mockedGetEarlyDiscountInfo.mockReturnValue(null); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.shouldShow50off).toBe(false); + expect(result.current.shouldShow25off).toBe(false); + }); + }); + + describe('when 50% discount should be shown', () => { + it('should return shouldShow50off as true when discountType is 50', () => { + mockedShouldShowDiscountBanner.mockReturnValue(true); + mockedGetEarlyDiscountInfo.mockReturnValue({discountType: 50, days: 1}); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.shouldShow50off).toBe(true); + expect(result.current.shouldShow25off).toBe(false); + }); + + it('should set discountInfo correctly when showing 50% off', () => { + mockedShouldShowDiscountBanner.mockReturnValue(true); + mockedGetEarlyDiscountInfo.mockReturnValue({discountType: 50, days: 1}); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.discountInfo).toEqual({discountType: 50, days: 1}); + }); + }); + + describe('when 25% discount should be shown', () => { + it('should return shouldShow25off as true when discountType is 25', () => { + mockedShouldShowDiscountBanner.mockReturnValue(true); + mockedGetEarlyDiscountInfo.mockReturnValue({discountType: 25, days: 5}); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.shouldShow50off).toBe(false); + expect(result.current.shouldShow25off).toBe(true); + }); + + it('should set discountInfo correctly when showing 25% off', () => { + mockedShouldShowDiscountBanner.mockReturnValue(true); + mockedGetEarlyDiscountInfo.mockReturnValue({discountType: 25, days: 10}); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.discountInfo).toEqual({discountType: 25, days: 10}); + }); + }); + + describe('discount type exclusivity', () => { + it('should only show one discount type at a time (50% takes precedence)', () => { + // When discount is 50%, only shouldShow50off should be true + mockedShouldShowDiscountBanner.mockReturnValue(true); + mockedGetEarlyDiscountInfo.mockReturnValue({discountType: 50, days: 1}); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.shouldShow50off).toBe(true); + expect(result.current.shouldShow25off).toBe(false); + }); + + it('should show 25% when discountType is 25', () => { + mockedShouldShowDiscountBanner.mockReturnValue(true); + mockedGetEarlyDiscountInfo.mockReturnValue({discountType: 25, days: 5}); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.shouldShow50off).toBe(false); + expect(result.current.shouldShow25off).toBe(true); + }); + }); + + describe('firstDayFreeTrial data', () => { + it('should return firstDayFreeTrial from Onyx', async () => { + const testDate = '2026-01-15'; + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, testDate); + await waitForBatchedUpdates(); + + mockedShouldShowDiscountBanner.mockReturnValue(false); + mockedGetEarlyDiscountInfo.mockReturnValue(null); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.firstDayFreeTrial).toBe(testDate); + }); + + it('should return undefined for firstDayFreeTrial when not set in Onyx', () => { + mockedShouldShowDiscountBanner.mockReturnValue(false); + mockedGetEarlyDiscountInfo.mockReturnValue(null); + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.firstDayFreeTrial).toBeUndefined(); + }); + }); + + describe('hook dependencies', () => { + it('should call shouldShowDiscountBanner with correct parameters', async () => { + const firstDayFreeTrial = '2026-01-15'; + const lastDayFreeTrial = '2026-01-22'; + const userBillingFundID = 12345; + + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, firstDayFreeTrial); + await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, lastDayFreeTrial); + await Onyx.merge(ONYXKEYS.NVP_BILLING_FUND_ID, userBillingFundID); + await waitForBatchedUpdates(); + + mockedUseHasTeam2025Pricing.mockReturnValue(true); + mockedUseSubscriptionPlan.mockReturnValue('team'); + mockedShouldShowDiscountBanner.mockReturnValue(false); + mockedGetEarlyDiscountInfo.mockReturnValue(null); + + renderHook(() => useTimeSensitiveOffers()); + + expect(mockedShouldShowDiscountBanner).toHaveBeenCalledWith( + true, // hasTeam2025Pricing + 'team', // subscriptionPlan + firstDayFreeTrial, + lastDayFreeTrial, + userBillingFundID, + ); + }); + + it('should call getEarlyDiscountInfo with firstDayFreeTrial', async () => { + const firstDayFreeTrial = '2026-01-15'; + + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, firstDayFreeTrial); + await waitForBatchedUpdates(); + + mockedShouldShowDiscountBanner.mockReturnValue(false); + mockedGetEarlyDiscountInfo.mockReturnValue(null); + + renderHook(() => useTimeSensitiveOffers()); + + expect(mockedGetEarlyDiscountInfo).toHaveBeenCalledWith(firstDayFreeTrial); + }); + }); + + describe('other discount types', () => { + it('should not show any discount when discountType is neither 50 nor 25', () => { + mockedShouldShowDiscountBanner.mockReturnValue(true); + mockedGetEarlyDiscountInfo.mockReturnValue({discountType: 10, days: 5}); // Some other discount type + + const {result} = renderHook(() => useTimeSensitiveOffers()); + + expect(result.current.shouldShow50off).toBe(false); + expect(result.current.shouldShow25off).toBe(false); + }); + }); +}); diff --git a/tests/unit/selectors/CardTest.ts b/tests/unit/selectors/CardTest.ts index 395ce3b10da51..904e66cb753ad 100644 --- a/tests/unit/selectors/CardTest.ts +++ b/tests/unit/selectors/CardTest.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import {defaultExpensifyCardSelector, filterCardsHiddenFromSearch} from '@selectors/Card'; +import {defaultExpensifyCardSelector, filterCardsHiddenFromSearch, timeSensitiveCardsSelector} from '@selectors/Card'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import type {Card, CardList} from '@src/types/onyx'; @@ -187,3 +187,187 @@ describe('defaultExpensifyCardSelector', () => { }); }); }); + +describe('timeSensitiveCardsSelector', () => { + it('returns empty arrays when cardList is undefined or empty', () => { + expect(timeSensitiveCardsSelector(undefined)).toEqual({ + cardsNeedingShippingAddress: [], + cardsNeedingActivation: [], + }); + expect(timeSensitiveCardsSelector({})).toEqual({ + cardsNeedingShippingAddress: [], + cardsNeedingActivation: [], + }); + }); + + it('returns empty arrays when no physical Expensify cards need action', () => { + const cardList: CardList = { + '1': createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}), + '2': createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.CLOSED}), + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsNeedingShippingAddress).toHaveLength(0); + expect(result.cardsNeedingActivation).toHaveLength(0); + }); + + it('identifies cards needing shipping address (STATE_NOT_ISSUED)', () => { + const cardNeedingShipping = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + const openCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}); + + const cardList: CardList = { + '1': cardNeedingShipping, + '2': openCard, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsNeedingShippingAddress).toHaveLength(1); + expect(result.cardsNeedingShippingAddress[0].cardID).toBe(1); + expect(result.cardsNeedingActivation).toHaveLength(0); + }); + + it('identifies cards needing activation (NOT_ACTIVATED)', () => { + const cardNeedingActivation = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + const openCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}); + + const cardList: CardList = { + '1': cardNeedingActivation, + '2': openCard, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsNeedingShippingAddress).toHaveLength(0); + expect(result.cardsNeedingActivation).toHaveLength(1); + expect(result.cardsNeedingActivation[0].cardID).toBe(1); + }); + + it('identifies multiple cards needing different actions', () => { + const cardNeedingShipping1 = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + const cardNeedingShipping2 = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + const cardNeedingActivation1 = createRandomExpensifyCard(3, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + const cardNeedingActivation2 = createRandomExpensifyCard(4, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + const openCard = createRandomExpensifyCard(5, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}); + + const cardList: CardList = { + '1': cardNeedingShipping1, + '2': cardNeedingShipping2, + '3': cardNeedingActivation1, + '4': cardNeedingActivation2, + '5': openCard, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsNeedingShippingAddress).toHaveLength(2); + expect(result.cardsNeedingActivation).toHaveLength(2); + }); + + it('excludes virtual Expensify cards from time-sensitive results', () => { + const virtualCardNeedingActivation: Card = { + ...createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}), + nameValuePairs: {isVirtual: true} as Card['nameValuePairs'], + }; + const physicalCardNeedingActivation = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + + const cardList: CardList = { + '1': virtualCardNeedingActivation, + '2': physicalCardNeedingActivation, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsNeedingActivation).toHaveLength(1); + expect(result.cardsNeedingActivation[0].cardID).toBe(2); + }); + + it('excludes non-Expensify cards (company cards) from time-sensitive results', () => { + const companyCard = createRandomCompanyCard(1, {bank: 'vcf'}); + // Manually set state to match pending issue + const companyCardWithPendingState: Card = { + ...companyCard, + state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED, + }; + const expensifyCardNeedingShipping = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + + const cardList: CardList = { + '1': companyCardWithPendingState, + '2': expensifyCardNeedingShipping, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsNeedingShippingAddress).toHaveLength(1); + expect(result.cardsNeedingShippingAddress[0].cardID).toBe(2); + }); + + it('filters out invalid card objects (missing cardID or bank)', () => { + const validCard = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + const invalidCard1 = {cardID: 2, state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED} as Card; + const invalidCard2 = {bank: CONST.EXPENSIFY_CARD.BANK, state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED} as Card; + + const cardList: CardList = { + '1': validCard, + '2': invalidCard1, + '3': invalidCard2, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsNeedingShippingAddress).toHaveLength(1); + expect(result.cardsNeedingShippingAddress[0].cardID).toBe(1); + }); + + it('handles mixed scenarios with various card types and states', () => { + const physicalExpensifyNeedingShipping = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + const physicalExpensifyNeedingActivation = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + const physicalExpensifyOpen = createRandomExpensifyCard(3, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}); + const virtualExpensifyNeedingActivation: Card = { + ...createRandomExpensifyCard(4, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}), + nameValuePairs: {isVirtual: true} as Card['nameValuePairs'], + }; + const companyCard = createRandomCompanyCard(5, {bank: 'vcf'}); + const suspendedCard = createRandomExpensifyCard(6, {state: CONST.EXPENSIFY_CARD.STATE.STATE_SUSPENDED}); + const closedCard = createRandomExpensifyCard(7, {state: CONST.EXPENSIFY_CARD.STATE.CLOSED}); + + const cardList: CardList = { + '1': physicalExpensifyNeedingShipping, + '2': physicalExpensifyNeedingActivation, + '3': physicalExpensifyOpen, + '4': virtualExpensifyNeedingActivation, + '5': companyCard, + '6': suspendedCard, + '7': closedCard, + }; + + const result = timeSensitiveCardsSelector(cardList); + + // Only physical Expensify cards with pending states should be included + expect(result.cardsNeedingShippingAddress).toHaveLength(1); + expect(result.cardsNeedingShippingAddress[0].cardID).toBe(1); + expect(result.cardsNeedingActivation).toHaveLength(1); + expect(result.cardsNeedingActivation[0].cardID).toBe(2); + }); + + it('returns cards in correct arrays based on their state', () => { + // Cards with STATE_NOT_ISSUED should be in cardsNeedingShippingAddress + // Cards with NOT_ACTIVATED should be in cardsNeedingActivation + const pendingIssueCard = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED}); + const pendingActivateCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED}); + + const cardList: CardList = { + '1': pendingIssueCard, + '2': pendingActivateCard, + }; + + const result = timeSensitiveCardsSelector(cardList); + + // Verify STATE_NOT_ISSUED (2) goes to cardsNeedingShippingAddress + expect(result.cardsNeedingShippingAddress.every((card) => card.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED)).toBe(true); + + // Verify NOT_ACTIVATED (4) goes to cardsNeedingActivation + expect(result.cardsNeedingActivation.every((card) => card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED)).toBe(true); + }); +}); From 0339f51956fa4d46553eec8356f60b26ec59ac7d Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Fri, 30 Jan 2026 14:36:05 -0800 Subject: [PATCH 3/9] Review card fraud widget --- src/languages/en.ts | 6 ++ src/languages/es.ts | 6 ++ .../hooks/useTimeSensitiveCards.ts | 4 + src/pages/home/TimeSensitiveSection/index.tsx | 13 ++- .../items/ReviewCardFraud.tsx | 90 +++++++++++++++++++ src/selectors/Card.ts | 31 +++++-- src/types/onyx/Card.ts | 29 ++++++ .../unit/hooks/useTimeSensitiveCards.test.ts | 29 ++++++ tests/unit/selectors/CardTest.ts | 88 +++++++++++++++++- 9 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx diff --git a/src/languages/en.ts b/src/languages/en.ts index 78fe93d1e5d07..9f8c5340c60ff 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1015,6 +1015,12 @@ const translations = { subtitle: 'Validate your card and start spending.', cta: 'Activate', }, + reviewCardFraud: { + title: 'Review potential fraud on your Expensify Card', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Review ${amount} in potential fraud at ${merchant}`, + subtitle: 'Expensify Card', + cta: 'Review', + }, }, announcements: 'Announcements', discoverSection: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 3a3e63299da42..91d7255d966c7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -760,6 +760,12 @@ const translations: TranslationDeepObject = { subtitle: 'Valida tu tarjeta y empieza a gastar.', cta: 'Activa', }, + reviewCardFraud: { + title: 'Review potential fraud on your Expensify Card', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => \`Review ${amount} in potential fraud at ${merchant}\`, + subtitle: 'Expensify Card', + cta: 'Review', + }, }, announcements: 'Anuncios', discoverSection: { diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts index 62ed4c8777c34..f64926663c270 100644 --- a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts @@ -10,15 +10,19 @@ function useTimeSensitiveCards() { const cardsNeedingShippingAddress = timeSensitiveCards?.cardsNeedingShippingAddress ?? []; const cardsNeedingActivation = timeSensitiveCards?.cardsNeedingActivation ?? []; + const cardsWithFraud = timeSensitiveCards?.cardsWithFraud ?? []; const shouldShowAddShippingAddress = cardsNeedingShippingAddress.length > 0; const shouldShowActivateCard = cardsNeedingActivation.length > 0; + const shouldShowReviewCardFraud = cardsWithFraud.length > 0; return { shouldShowAddShippingAddress, shouldShowActivateCard, + shouldShowReviewCardFraud, cardsNeedingShippingAddress, cardsNeedingActivation, + cardsWithFraud, }; } diff --git a/src/pages/home/TimeSensitiveSection/index.tsx b/src/pages/home/TimeSensitiveSection/index.tsx index 09f45482b1bfa..27cc38db028eb 100644 --- a/src/pages/home/TimeSensitiveSection/index.tsx +++ b/src/pages/home/TimeSensitiveSection/index.tsx @@ -13,6 +13,7 @@ import ActivateCard from './items/ActivateCard'; import AddShippingAddress from './items/AddShippingAddress'; import Offer25off from './items/Offer25off'; import Offer50off from './items/Offer50off'; +import ReviewCardFraud from './items/ReviewCardFraud'; function TimeSensitiveSection() { const styles = useThemeStyles(); @@ -22,9 +23,9 @@ function TimeSensitiveSection() { const {shouldUseNarrowLayout} = useResponsiveLayout(); const {shouldShow50off, shouldShow25off, firstDayFreeTrial, discountInfo} = useTimeSensitiveOffers(); - const {shouldShowAddShippingAddress, shouldShowActivateCard, cardsNeedingShippingAddress, cardsNeedingActivation} = useTimeSensitiveCards(); + const {shouldShowAddShippingAddress, shouldShowActivateCard, shouldShowReviewCardFraud, cardsNeedingShippingAddress, cardsNeedingActivation, cardsWithFraud} = useTimeSensitiveCards(); - const hasAnyItemToShow = shouldShow50off || shouldShow25off || shouldShowAddShippingAddress || shouldShowActivateCard; + const hasAnyItemToShow = shouldShowReviewCardFraud || shouldShow50off || shouldShow25off || shouldShowAddShippingAddress || shouldShowActivateCard; if (!hasAnyItemToShow) { return null; @@ -40,6 +41,14 @@ function TimeSensitiveSection() { titleColor={theme.danger} > + {/* Priority order: 1. Fraud, 2. Discounts, 3. Shipping, 4. Activation */} + {shouldShowReviewCardFraud && + cardsWithFraud.map((card) => ( + + ))} {shouldShow50off && } {shouldShow25off && !!discountInfo && } {shouldShowAddShippingAddress && diff --git a/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx b/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx new file mode 100644 index 0000000000000..67aff5ddaae14 --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx @@ -0,0 +1,90 @@ +import React, {useMemo} from 'react'; +import ExpensifyCardIcon from '@assets/images/expensify-card-icon.svg'; +import BaseWidgetItem from '@components/BaseWidgetItem'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import colors from '@styles/theme/colors'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Card} from '@src/types/onyx'; +import type {OriginalMessageCardFraudAlert} from '@src/types/onyx/OriginalMessage'; + +type ReviewCardFraudProps = { + /** The card with potential fraud */ + card: Card; +}; + +function ReviewCardFraud({card}: ReviewCardFraudProps) { + const {translate} = useLocalize(); + + // Get the fraud alert report ID and action ID for deeplink navigation + const fraudAlertReportID = card.message?.possibleFraud?.fraudAlertReportID; + const fraudAlertReportActionID = card.message?.possibleFraud?.fraudAlertReportActionID; + + // Fetch the report actions to get the fraud details (amount, merchant, currency) + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fraudAlertReportID}`, { + canBeMissing: true, + }); + + // Extract fraud details from the report action + const fraudDetails = useMemo(() => { + if (!fraudAlertReportActionID || !reportActions) { + return null; + } + + const fraudAction = reportActions[fraudAlertReportActionID]; + if (!fraudAction || fraudAction.actionName !== CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT) { + return null; + } + + const message = fraudAction.originalMessage as OriginalMessageCardFraudAlert | undefined; + if (!message) { + return null; + } + + return { + triggerAmount: message.triggerAmount, + triggerMerchant: message.triggerMerchant, + currency: message.currency ?? CONST.CURRENCY.USD, + }; + }, [fraudAlertReportActionID, reportActions]); + + const handleReviewPress = () => { + if (!fraudAlertReportID) { + return; + } + + // Navigate to the report containing the fraud alert action + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(String(fraudAlertReportID))); + }; + + // Generate the title with amount and merchant if available + const title = useMemo(() => { + if (fraudDetails) { + const formattedAmount = convertToDisplayString(fraudDetails.triggerAmount, fraudDetails.currency); + return translate('homePage.timeSensitiveSection.reviewCardFraud.titleWithDetails', { + amount: formattedAmount, + merchant: fraudDetails.triggerMerchant, + }); + } + return translate('homePage.timeSensitiveSection.reviewCardFraud.title'); + }, [fraudDetails, translate]); + + return ( + + ); +} + +export default ReviewCardFraud; diff --git a/src/selectors/Card.ts b/src/selectors/Card.ts index b60a5b804bab3..acf1572b41c1a 100644 --- a/src/selectors/Card.ts +++ b/src/selectors/Card.ts @@ -45,16 +45,26 @@ const cardByIdSelector = (cardID: string) => (cardList: OnyxEntry) => type TimeSensitiveCardsResult = { cardsNeedingShippingAddress: Card[]; cardsNeedingActivation: Card[]; + cardsWithFraud: Card[]; }; /** - * Selector that filters cards to find physical Expensify cards that need shipping address or activation. - * Returns two arrays: cards pending issue (need shipping) and cards pending activation. + * Check if a card has potential fraud that needs review. + * Returns true if the card has fraud type 'domain' or 'individual'. + */ +const isCardWithPotentialFraud = (card: Card): boolean => { + return card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL; +}; + +/** + * Selector that filters cards to find Expensify cards that need time-sensitive action. + * Returns arrays for: cards with potential fraud, cards pending issue (need shipping), and cards pending activation. */ const timeSensitiveCardsSelector = (cards: OnyxEntry): TimeSensitiveCardsResult => { const result: TimeSensitiveCardsResult = { cardsNeedingShippingAddress: [], cardsNeedingActivation: [], + cardsWithFraud: [], }; for (const card of Object.values(cards ?? {})) { @@ -62,9 +72,20 @@ const timeSensitiveCardsSelector = (cards: OnyxEntry): TimeSensitiveCa continue; } - // Only consider physical Expensify cards - const isPhysicalExpensifyCard = card.bank === CONST.EXPENSIFY_CARD.BANK && !card.nameValuePairs?.isVirtual; - if (!isPhysicalExpensifyCard) { + // Only consider Expensify cards + const isExpensifyCard = card.bank === CONST.EXPENSIFY_CARD.BANK; + if (!isExpensifyCard) { + continue; + } + + // Check for fraud on any Expensify card (physical or virtual) + if (isCardWithPotentialFraud(card)) { + result.cardsWithFraud.push(card); + } + + // Physical card checks (shipping address and activation) + const isPhysicalCard = !card.nameValuePairs?.isVirtual; + if (!isPhysicalCard) { continue; } diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index 1782702a80aa9..6a0cc6f2d5b21 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -13,6 +13,30 @@ type CardStatusChanges = { status: ValueOf; }; +/** Model of possible fraud data stored on a card */ +type PossibleFraudData = { + /** Fraud state of the card */ + state?: number; + + /** Date when fraud was detected */ + date?: string; + + /** Card ID that triggered the fraud detection (for domain-level fraud) */ + triggerCardID?: number; + + /** Report ID for the fraud alert action (used for deeplink) */ + fraudAlertReportID?: number; + + /** Report action ID for the fraud alert (used for deeplink) */ + fraudAlertReportActionID?: number; +}; + +/** Model of card message data */ +type CardMessage = { + /** Possible fraud information */ + possibleFraud?: PossibleFraudData; +}; + /** Model of Expensify card */ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Card ID number */ @@ -60,6 +84,9 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Current fraud state of the card */ fraud: ValueOf; + /** Card message data containing possible fraud info and other metadata */ + message?: CardMessage; + /** Card name */ cardName?: string; @@ -374,4 +401,6 @@ export type { ProvisioningCardData, AssignableCardsList, UnassignedCard, + CardMessage, + PossibleFraudData, }; diff --git a/tests/unit/hooks/useTimeSensitiveCards.test.ts b/tests/unit/hooks/useTimeSensitiveCards.test.ts index b4707afcd6f7b..bb280f78a371f 100644 --- a/tests/unit/hooks/useTimeSensitiveCards.test.ts +++ b/tests/unit/hooks/useTimeSensitiveCards.test.ts @@ -27,8 +27,10 @@ describe('useTimeSensitiveCards', () => { expect(result.current.cardsNeedingShippingAddress).toEqual([]); expect(result.current.cardsNeedingActivation).toEqual([]); + expect(result.current.cardsWithFraud).toEqual([]); expect(result.current.shouldShowAddShippingAddress).toBe(false); expect(result.current.shouldShowActivateCard).toBe(false); + expect(result.current.shouldShowReviewCardFraud).toBe(false); }); it('should return empty arrays when no cards need action', async () => { @@ -168,4 +170,31 @@ describe('useTimeSensitiveCards', () => { expect(result.current.cardsNeedingActivation).toHaveLength(1); expect(result.current.shouldShowActivateCard).toBe(true); }); + + it('should identify cards with fraud and set shouldShowReviewCardFraud to true', async () => { + const cardWithFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN}); + const cardList: CardList = {'1': cardWithFraud}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsWithFraud).toHaveLength(1); + expect(result.current.cardsWithFraud[0].cardID).toBe(1); + expect(result.current.shouldShowReviewCardFraud).toBe(true); + }); + + it('should not show fraud review for cards with fraud type NONE', async () => { + const cardWithNoFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}); + const cardList: CardList = {'1': cardWithNoFraud}; + + await Onyx.merge(ONYXKEYS.CARD_LIST, cardList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveCards()); + + expect(result.current.cardsWithFraud).toHaveLength(0); + expect(result.current.shouldShowReviewCardFraud).toBe(false); + }); }); diff --git a/tests/unit/selectors/CardTest.ts b/tests/unit/selectors/CardTest.ts index 904e66cb753ad..624077fd14fd6 100644 --- a/tests/unit/selectors/CardTest.ts +++ b/tests/unit/selectors/CardTest.ts @@ -193,23 +193,26 @@ describe('timeSensitiveCardsSelector', () => { expect(timeSensitiveCardsSelector(undefined)).toEqual({ cardsNeedingShippingAddress: [], cardsNeedingActivation: [], + cardsWithFraud: [], }); expect(timeSensitiveCardsSelector({})).toEqual({ cardsNeedingShippingAddress: [], cardsNeedingActivation: [], + cardsWithFraud: [], }); }); it('returns empty arrays when no physical Expensify cards need action', () => { const cardList: CardList = { - '1': createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN}), - '2': createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.CLOSED}), + '1': createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}), + '2': createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.CLOSED, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}), }; const result = timeSensitiveCardsSelector(cardList); expect(result.cardsNeedingShippingAddress).toHaveLength(0); expect(result.cardsNeedingActivation).toHaveLength(0); + expect(result.cardsWithFraud).toHaveLength(0); }); it('identifies cards needing shipping address (STATE_NOT_ISSUED)', () => { @@ -370,4 +373,85 @@ describe('timeSensitiveCardsSelector', () => { // Verify NOT_ACTIVATED (4) goes to cardsNeedingActivation expect(result.cardsNeedingActivation.every((card) => card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED)).toBe(true); }); + + it('identifies cards with domain fraud', () => { + const cardWithDomainFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN}); + const normalCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}); + + const cardList: CardList = { + '1': cardWithDomainFraud, + '2': normalCard, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsWithFraud).toHaveLength(1); + expect(result.cardsWithFraud[0].cardID).toBe(1); + expect(result.cardsWithFraud[0].fraud).toBe(CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN); + }); + + it('identifies cards with individual fraud', () => { + const cardWithIndividualFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL}); + const normalCard = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}); + + const cardList: CardList = { + '1': cardWithIndividualFraud, + '2': normalCard, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsWithFraud).toHaveLength(1); + expect(result.cardsWithFraud[0].cardID).toBe(1); + expect(result.cardsWithFraud[0].fraud).toBe(CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL); + }); + + it('detects fraud on both physical and virtual Expensify cards', () => { + const physicalCardWithFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN}); + const virtualCardWithFraud: Card = { + ...createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL}), + nameValuePairs: {isVirtual: true} as Card['nameValuePairs'], + }; + + const cardList: CardList = { + '1': physicalCardWithFraud, + '2': virtualCardWithFraud, + }; + + const result = timeSensitiveCardsSelector(cardList); + + // Both physical and virtual cards with fraud should be included + expect(result.cardsWithFraud).toHaveLength(2); + }); + + it('excludes non-Expensify cards from fraud detection', () => { + const companyCardWithFraud: Card = { + ...createRandomCompanyCard(1, {bank: 'vcf'}), + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN, + }; + const expensifyCardWithFraud = createRandomExpensifyCard(2, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN}); + + const cardList: CardList = { + '1': companyCardWithFraud, + '2': expensifyCardWithFraud, + }; + + const result = timeSensitiveCardsSelector(cardList); + + // Only Expensify card should be included + expect(result.cardsWithFraud).toHaveLength(1); + expect(result.cardsWithFraud[0].cardID).toBe(2); + }); + + it('does not include cards with fraud type NONE', () => { + const cardWithNoFraud = createRandomExpensifyCard(1, {state: CONST.EXPENSIFY_CARD.STATE.OPEN, fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE}); + + const cardList: CardList = { + '1': cardWithNoFraud, + }; + + const result = timeSensitiveCardsSelector(cardList); + + expect(result.cardsWithFraud).toHaveLength(0); + }); }); From 71de3335dd5dcaea4c59b00270504bdafa0e5d65 Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Fri, 30 Jan 2026 14:46:49 -0800 Subject: [PATCH 4/9] Prettier and languages --- src/languages/de.ts | 10 ++++++++-- src/languages/es.ts | 4 ++-- src/languages/fr.ts | 10 ++++++++-- src/languages/it.ts | 12 +++++++++--- src/languages/ja.ts | 11 +++++++++-- src/languages/nl.ts | 8 +++++++- src/languages/pl.ts | 10 ++++++++-- src/languages/pt-BR.ts | 10 ++++++++-- src/languages/zh-hans.ts | 12 +++++++++--- .../hooks/useTimeSensitiveCards.ts | 2 +- tests/unit/hooks/useTimeSensitiveCards.test.ts | 2 +- tests/unit/hooks/useTimeSensitiveOffers.test.ts | 9 ++++----- 12 files changed, 74 insertions(+), 26 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index a5904b5ce028e..eb14d022c1054 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -771,7 +771,7 @@ const translations: TranslationDeepObject = { revoke: 'Widerrufen', title: 'Gesicht/Fingerabdruck & Passkeys', explanation: - 'Die Gesichts-/Fingerabdruck- oder Passkey-Verifizierung ist auf einem oder mehreren Geräten aktiviert. Durch das Widerrufen des Zugriffs wird für die nächste Verifizierung auf jedem Gerät ein magischer Code erforderlich', + 'Die Gesichts-/Fingerabdruck- oder Passkey-Verifizierung ist auf einem oder mehreren Geräten aktiviert. Das Widerrufen des Zugriffs erfordert für die nächste Verifizierung auf jedem Gerät einen Magic Code', confirmationPrompt: 'Bist du sicher? Du benötigst einen magischen Code für die nächste Verifizierung auf jedem Gerät', cta: 'Zugriff widerrufen', noDevices: 'Du hast keine Geräte für Gesichts-/Fingerabdruck- oder Passkey-Verifizierung registriert. Wenn du welche registrierst, kannst du den Zugriff hier widerrufen.', @@ -7240,7 +7240,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard addedConnection: ({connectionName}: ConnectionNameParams) => `verbunden mit ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'hat den Chat verlassen', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `Die ${feedName}-Verbindung ist unterbrochen. Um Kartenimporte wiederherzustellen, melden Sie sich bei Ihrer Bank an`, + `Die Verbindung zu ${feedName} ist unterbrochen. Um Kartenimporte wiederherzustellen, melden Sie sich bei Ihrer Bank an`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `die Plaid-Verbindung zu Ihrem Geschäftsbankkonto ist unterbrochen. Bitte verbinden Sie Ihr Bankkonto ${maskedAccountNumber} erneut, damit Sie Ihre Expensify-Karten weiterhin nutzen können.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8307,6 +8307,12 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, offer25off: {title: 'Erhalten Sie 25 % Rabatt auf Ihr erstes Jahr!', subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'Tag' : 'Tage'} verbleiben`}, addShippingAddress: {title: 'Wir benötigen Ihre Versandadresse', subtitle: 'Gib eine Adresse an, um deine Expensify Card zu erhalten.', cta: 'Adresse hinzufügen'}, activateCard: {title: 'Aktiviere deine Expensify Card', subtitle: 'Bestätige deine Karte und beginne mit dem Ausgeben.', cta: 'Aktivieren'}, + reviewCardFraud: { + title: 'Verdächtige Aktivitäten auf Ihrer Expensify Card überprüfen', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Überprüfe ${amount} an potenziellem Betrug bei ${merchant}`, + subtitle: 'Expensify Card', + cta: 'Überprüfen', + }, }, }, }; diff --git a/src/languages/es.ts b/src/languages/es.ts index 91d7255d966c7..d32ec309beeb8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -762,9 +762,9 @@ const translations: TranslationDeepObject = { }, reviewCardFraud: { title: 'Review potential fraud on your Expensify Card', - titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => \`Review ${amount} in potential fraud at ${merchant}\`, + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Review ${amount} in potential fraud at ${merchant}`, subtitle: 'Expensify Card', - cta: 'Review', + cta: 'Revisar', }, }, announcements: 'Anuncios', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 55297041b7030..e2a204e16a4bd 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -774,7 +774,7 @@ const translations: TranslationDeepObject = { revoke: 'Révoquer', title: 'Reconnaissance faciale/empreinte digitale et passkeys', explanation: - 'La vérification par reconnaissance faciale/empreinte digitale ou par passkey est activée sur un ou plusieurs appareils. Révoquer l’accès exigera un code magique pour la prochaine vérification sur n’importe quel appareil', + 'La vérification par reconnaissance faciale/empreinte digitale ou par passkey est activée sur un ou plusieurs appareils. La révocation de l’accès nécessitera un code magique pour la prochaine vérification sur n’importe quel appareil', confirmationPrompt: 'Êtes-vous sûr ? Vous aurez besoin d’un code magique pour la prochaine vérification sur n’importe quel appareil', cta: 'Révoquer l’accès', noDevices: @@ -7252,7 +7252,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin addedConnection: ({connectionName}: ConnectionNameParams) => `connecté à ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'a quitté la discussion', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `La connexion à ${feedName} est rompue. Pour rétablir l'importation des cartes, connectez-vous à votre banque`, + `La connexion ${feedName} est interrompue. Pour rétablir l’importation des cartes, connectez-vous à votre banque`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `la connexion Plaid à votre compte bancaire professionnel est interrompue. Veuillez reconnecter votre compte bancaire ${maskedAccountNumber} pour continuer à utiliser vos cartes Expensify.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8313,6 +8313,12 @@ Voici un *reçu test* pour vous montrer comment cela fonctionne :`, offer25off: {title: 'Obtenez 25 % de réduction sur votre première année !', subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'jour' : 'jours'} restants`}, addShippingAddress: {title: 'Nous avons besoin de votre adresse de livraison', subtitle: 'Indiquez une adresse pour recevoir votre carte Expensify.', cta: 'Ajouter une adresse'}, activateCard: {title: 'Activer votre carte Expensify', subtitle: 'Validez votre carte et commencez à dépenser.', cta: 'Activer'}, + reviewCardFraud: { + title: 'Examiner un éventuel cas de fraude sur votre carte Expensify', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Examiner ${amount} de fraude potentielle chez ${merchant}`, + subtitle: 'Carte Expensify', + cta: 'Vérifier', + }, }, }, }; diff --git a/src/languages/it.ts b/src/languages/it.ts index 169e7064b93c1..87561d9ddde7f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -772,8 +772,8 @@ const translations: TranslationDeepObject = { revoke: 'Revoca', title: 'Riconoscimento facciale/impronta digitale & passkey', explanation: - 'La verifica con volto/impronta digitale o passkey è abilitata su uno o più dispositivi. La revoca dell’accesso richiederà un codice magico per la prossima verifica su qualsiasi dispositivo', - confirmationPrompt: 'Sei sicuro? Ti servirà un codice magico per la prossima verifica su qualsiasi dispositivo', + 'La verifica con volto/impronta o passkey è abilitata su uno o più dispositivi. La revoca dell’accesso richiederà un codice magico per la prossima verifica su qualsiasi dispositivo', + confirmationPrompt: 'Sei sicuro? Avrai bisogno di un codice magico per la prossima verifica su qualsiasi dispositivo', cta: 'Revoca accesso', noDevices: 'Non hai alcun dispositivo registrato per la verifica con volto/impronta digitale o passkey. Se ne registri uno, potrai revocare tale accesso da qui.', dismiss: 'Ho capito', @@ -7229,7 +7229,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori addedConnection: ({connectionName}: ConnectionNameParams) => `connesso a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'ha lasciato la chat', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `La connessione ${feedName} non funziona. Per ripristinare le importazioni delle carte, accedi alla tua banca`, + `La connessione ${feedName} è interrotta. Per ripristinare l’importazione delle carte, accedi alla tua banca`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `la connessione Plaid al tuo conto bancario aziendale è interrotta. Ricollega il tuo conto bancario ${maskedAccountNumber} per continuare a usare le tue carte Expensify.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8293,6 +8293,12 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, offer25off: {title: 'Ottieni il 25% di sconto sul tuo primo anno!', subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'giorno' : 'giorni'} rimanenti`}, addShippingAddress: {title: 'Abbiamo bisogno del tuo indirizzo di spedizione', subtitle: 'Fornisci un indirizzo per ricevere la tua Expensify Card.', cta: 'Aggiungi indirizzo'}, activateCard: {title: 'Attiva la tua Expensify Card', subtitle: 'Convalida la tua carta e inizia a spendere.', cta: 'Attiva'}, + reviewCardFraud: { + title: 'Esamina potenziali frodi sulla tua Expensify Card', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Esamina ${amount} di potenziale frode presso ${merchant}`, + subtitle: 'Carta Expensify', + cta: 'Rivedi', + }, }, }, }; diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c4669e8b00932..ad8ae42dee509 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -770,8 +770,9 @@ const translations: TranslationDeepObject = { revoke: { revoke: '取り消す', title: '顔/指紋 & パスキー', - explanation: '1 台以上のデバイスで顔 / 指紋またはパスキー認証が有効になっています。アクセスを取り消すと、今後どのデバイスでも次回の認証時にマジックコードが必要になります', - confirmationPrompt: '本当に実行しますか?次回、どのデバイスで確認する場合でも、マジックコードが必要になります', + explanation: + '1 つ以上のデバイスで、顔 / 指紋またはパスキーによる認証が有効になっています。アクセスを取り消すと、次回どのデバイスであっても、認証にはマジックコードが必要になります', + confirmationPrompt: '本当に実行しますか?今後、どのデバイスでも次回の認証にはマジックコードが必要になります', cta: 'アクセスを取り消す', noDevices: '顔認証 / 指紋認証 またはパスキー認証用に登録されているデバイスがありません。 \nいずれかを登録すると、ここでそのアクセスを取り消せるようになります。', dismiss: '了解', @@ -8204,6 +8205,12 @@ Expensify の使い方をお見せするための*テストレシート*がこ offer25off: {title: '初年度が25%オフ!', subtitle: ({days}: {days: number}) => `残り ${days} ${days === 1 ? '日' : '日'}`}, addShippingAddress: {title: '配送先住所が必要です', subtitle: 'Expensify Card を受け取る住所を入力してください。', cta: '住所を追加'}, activateCard: {title: 'Expensify Card を有効化', subtitle: 'カードを認証して、すぐに支出を始めましょう。', cta: '有効化'}, + reviewCardFraud: { + title: 'Expensify Card の不正利用の可能性を確認する', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `${merchant} における潜在的な不正行為の ${amount} を確認`, + subtitle: 'Expensify カード', + cta: 'レビュー', + }, }, }, }; diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e6bd121bd966e..6636a8ad9226c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7212,7 +7212,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten addedConnection: ({connectionName}: ConnectionNameParams) => `verbonden met ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'heeft de chat verlaten', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `De ${feedName}-verbinding is verbroken. Om kaartimporten te herstellen, log in bij uw bank`, + `De ${feedName}-verbinding is verbroken. Om kaartimports te herstellen, log in bij uw bank`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `de Plaid-verbinding met uw zakelijke bankrekening is verbroken. Verbind uw bankrekening ${maskedAccountNumber} opnieuw om uw Expensify-kaarten te kunnen blijven gebruiken.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8269,6 +8269,12 @@ Hier is een *testbon* om je te laten zien hoe het werkt:`, offer25off: {title: 'Krijg 25% korting op je eerste jaar!', subtitle: ({days}: {days: number}) => `Nog ${days} ${days === 1 ? 'dag' : 'dagen'} resterend`}, addShippingAddress: {title: 'We hebben je verzendadres nodig', subtitle: 'Voer een adres in om je Expensify Card te ontvangen.', cta: 'Adres toevoegen'}, activateCard: {title: 'Activeer je Expensify Card', subtitle: 'Valideer je kaart en begin met uitgeven.', cta: 'Activeren'}, + reviewCardFraud: { + title: 'Controleer mogelijke fraude op je Expensify Card', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Controleer ${amount} aan mogelijke fraude bij ${merchant}`, + subtitle: 'Expensify Card', + cta: 'Beoordelen', + }, }, }, }; diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8953295506bae..273180ab76820 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -769,10 +769,10 @@ const translations: TranslationDeepObject = { biometrics: 'Włącz szybką i bezpieczną weryfikację za pomocą twarzy lub odcisku palca. Bez haseł ani kodów.', }, revoke: { - revoke: 'Unieważnij', + revoke: 'Odwołaj', title: 'Rozpoznawanie twarzy/odcisk palca i klucze dostępu', explanation: - 'Weryfikacja za pomocą twarzy/odcisku palca lub klucza dostępu (passkey) jest włączona na jednym lub większej liczbie urządzeń. Cofnięcie dostępu spowoduje, że przy następnej weryfikacji na dowolnym urządzeniu wymagany będzie magiczny kod', + 'Weryfikacja za pomocą twarzy/odcisku palca lub klucza dostępu jest włączona na jednym lub większej liczbie urządzeń. Cofnięcie dostępu spowoduje, że przy następnej weryfikacji na dowolnym urządzeniu wymagany będzie magiczny kod', confirmationPrompt: 'Czy na pewno? Będziesz potrzebować magicznego kodu do następnej weryfikacji na dowolnym urządzeniu', cta: 'Cofnij dostęp', noDevices: @@ -8253,6 +8253,12 @@ Oto *paragon testowy*, który pokazuje, jak to działa:`, offer25off: {title: 'Uzyskaj 25% zniżki na pierwszy rok!', subtitle: ({days}: {days: number}) => `Pozostało ${days} ${days === 1 ? 'dzień' : 'dni'}`}, addShippingAddress: {title: 'Potrzebujemy Twojego adresu do wysyłki', subtitle: 'Podaj adres, na który mamy wysłać Twoją kartę Expensify.', cta: 'Dodaj adres'}, activateCard: {title: 'Aktywuj swoją kartę Expensify', subtitle: 'Zweryfikuj swoją kartę i zacznij wydawać.', cta: 'Aktywuj'}, + reviewCardFraud: { + title: 'Przejrzyj potencjalne oszustwo na swojej karcie Expensify', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Sprawdź ${amount} pod kątem potencjalnego oszustwa u ${merchant}`, + subtitle: 'Karta Expensify', + cta: 'Przejrzyj', + }, }, }, }; diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 74bc594d78d7e..430b1dbb06c0d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -771,7 +771,7 @@ const translations: TranslationDeepObject = { revoke: 'Revogar', title: 'Rosto/digital & chaves de acesso', explanation: - 'A verificação por rosto/digital ou chave de acesso está ativada em um ou mais dispositivos. Revogar o acesso exigirá um código mágico para a próxima verificação em qualquer dispositivo', + 'A verificação por rosto/digital ou por chave de acesso está ativada em um ou mais dispositivos. Revogar o acesso exigirá um código mágico para a próxima verificação em qualquer dispositivo', confirmationPrompt: 'Tem certeza? Você precisará de um código mágico para a próxima verificação em qualquer dispositivo', cta: 'Revogar acesso', noDevices: 'Você não tem nenhum dispositivo registrado para verificação por rosto/digital ou passkey. Se você registrar algum, poderá revogar esse acesso aqui.', @@ -7201,7 +7201,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe addedConnection: ({connectionName}: ConnectionNameParams) => `conectado a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'saiu do chat', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `A conexão ${feedName} está quebrada. Para restaurar as importações do cartão, faça login no seu banco`, + `A conexão ${feedName} está quebrada. Para restaurar as importações de cartão, faça login no seu banco`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `a conexão Plaid com sua conta bancária empresarial está quebrada. Por favor, reconecte sua conta bancária ${maskedAccountNumber} para continuar usando seus Cartões Expensify.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8263,6 +8263,12 @@ Aqui está um *recibo de teste* para mostrar como funciona:`, offer25off: {title: 'Ganhe 25% de desconto no seu primeiro ano!', subtitle: ({days}: {days: number}) => `${days} ${days === 1 ? 'dia' : 'dias'} restantes`}, addShippingAddress: {title: 'Precisamos do seu endereço de entrega', subtitle: 'Forneça um endereço para receber seu Expensify Card.', cta: 'Adicionar endereço'}, activateCard: {title: 'Ative seu Cartão Expensify', subtitle: 'Valide seu cartão e comece a gastar.', cta: 'Ativar'}, + reviewCardFraud: { + title: 'Analisar possível fraude no seu Cartão Expensify', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Revisar ${amount} em potencial fraude em ${merchant}`, + subtitle: 'Cartão Expensify', + cta: 'Revisar', + }, }, }, }; diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 41c860b495bc3..c99431e09d590 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -766,8 +766,8 @@ const translations: TranslationDeepObject = { revoke: { revoke: '撤销', title: '面部识别/指纹识别与通行密钥', - explanation: '在一台或多台设备上已启用面部 / 指纹或通行密钥验证。撤销访问权限后,下次在任何设备上进行验证时都需要使用魔法验证码', - confirmationPrompt: '你确定吗?在任何设备上进行下一步验证时,你都需要一个魔法代码', + explanation: '在一台或多台设备上已启用面部/指纹或通行密钥验证。撤销访问后,下次在任意设备上进行验证时都需要使用魔法代码', + confirmationPrompt: '您确定吗?您在任何设备上的下一次验证都需要一个魔法代码', cta: '撤销访问权限', noDevices: '您尚未注册任何用于人脸/指纹或通行密钥验证的设备。如果您注册了设备,您将可以在此撤销其访问权限。', dismiss: '明白了', @@ -7040,7 +7040,7 @@ ${reportName} addedConnection: ({connectionName}: ConnectionNameParams) => `已连接到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: '已离开聊天', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `${feedName} 连接已中断。要恢复卡片导入,请登录到您的银行`, + `${feedName} 连接已中断。若要恢复卡片导入,请登录您的银行账户`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `您的企业银行账户的 Plaid 连接已中断。请重新连接您的银行账户 ${maskedAccountNumber},以便继续使用您的 Expensify 卡。`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8021,6 +8021,12 @@ ${reportName} offer25off: {title: '首次年度订阅立享 25% 折扣!', subtitle: ({days}: {days: number}) => `剩余 ${days} ${days === 1 ? '天' : '天'}`}, addShippingAddress: {title: '我们需要您的收货地址', subtitle: '请提供一个地址以接收您的 Expensify Card。', cta: '添加地址'}, activateCard: {title: '激活您的 Expensify Card', subtitle: '验证您的卡片并开始消费。', cta: '激活'}, + reviewCardFraud: { + title: '查看您的 Expensify Card 上的潜在欺诈行为', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `在 ${merchant} 审核可能存在欺诈的金额 ${amount}`, + subtitle: 'Expensify Card', + cta: '审核', + }, }, }, }; diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts index f64926663c270..c1b646fb256e6 100644 --- a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards.ts @@ -1,5 +1,5 @@ -import useOnyx from '@hooks/useOnyx'; import {timeSensitiveCardsSelector} from '@selectors/Card'; +import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; function useTimeSensitiveCards() { diff --git a/tests/unit/hooks/useTimeSensitiveCards.test.ts b/tests/unit/hooks/useTimeSensitiveCards.test.ts index bb280f78a371f..abc2a9e6e2cf9 100644 --- a/tests/unit/hooks/useTimeSensitiveCards.test.ts +++ b/tests/unit/hooks/useTimeSensitiveCards.test.ts @@ -5,8 +5,8 @@ import useTimeSensitiveCards from '@pages/home/TimeSensitiveSection/hooks/useTim import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, CardList} from '@src/types/onyx'; -import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; import {createRandomExpensifyCard} from '../../utils/collections/card'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; describe('useTimeSensitiveCards', () => { beforeAll(() => { diff --git a/tests/unit/hooks/useTimeSensitiveOffers.test.ts b/tests/unit/hooks/useTimeSensitiveOffers.test.ts index 824c1fd465d66..4a093afe1fc97 100644 --- a/tests/unit/hooks/useTimeSensitiveOffers.test.ts +++ b/tests/unit/hooks/useTimeSensitiveOffers.test.ts @@ -1,6 +1,10 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; +// Import mocks after they're defined +import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing'; +import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; +import {getEarlyDiscountInfo, shouldShowDiscountBanner} from '@libs/SubscriptionUtils'; import useTimeSensitiveOffers from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveOffers'; import ONYXKEYS from '@src/ONYXKEYS'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -21,11 +25,6 @@ jest.mock('@libs/SubscriptionUtils', () => ({ getEarlyDiscountInfo: jest.fn(() => null), })); -// Import mocks after they're defined -import useHasTeam2025Pricing from '@hooks/useHasTeam2025Pricing'; -import useSubscriptionPlan from '@hooks/useSubscriptionPlan'; -import {getEarlyDiscountInfo, shouldShowDiscountBanner} from '@libs/SubscriptionUtils'; - const mockedUseHasTeam2025Pricing = useHasTeam2025Pricing as jest.Mock; const mockedUseSubscriptionPlan = useSubscriptionPlan as jest.Mock; const mockedShouldShowDiscountBanner = shouldShowDiscountBanner as jest.Mock; From 23608c75f07eabc720752ed7bb44210d5275fddf Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Fri, 30 Jan 2026 14:58:30 -0800 Subject: [PATCH 5/9] Confirmed spanish translations --- src/languages/es.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/languages/es.ts b/src/languages/es.ts index d32ec309beeb8..c5f74b53c7309 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -761,9 +761,9 @@ const translations: TranslationDeepObject = { cta: 'Activa', }, reviewCardFraud: { - title: 'Review potential fraud on your Expensify Card', - titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Review ${amount} in potential fraud at ${merchant}`, - subtitle: 'Expensify Card', + title: 'Revisa un posible fraude en tu tarjeta Expensify', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Revisa ${amount} en posible fraude en ${merchant}`, + subtitle: 'Tarjeta Expensify', cta: 'Revisar', }, }, From cf8850fefacc743d8b4446892d49d98cb8209a62 Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Fri, 30 Jan 2026 15:06:05 -0800 Subject: [PATCH 6/9] Parrot --- src/languages/de.ts | 6 +++--- src/languages/fr.ts | 4 ++-- src/languages/it.ts | 6 +++--- src/languages/ja.ts | 11 +++++------ src/languages/nl.ts | 6 +++--- src/languages/pl.ts | 6 +++--- src/languages/pt-BR.ts | 4 ++-- src/languages/zh-hans.ts | 10 +++++----- .../TimeSensitiveSection/items/ReviewCardFraud.tsx | 7 ++++--- 9 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index eb14d022c1054..22442d426e367 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7240,7 +7240,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard addedConnection: ({connectionName}: ConnectionNameParams) => `verbunden mit ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'hat den Chat verlassen', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `Die Verbindung zu ${feedName} ist unterbrochen. Um Kartenimporte wiederherzustellen, melden Sie sich bei Ihrer Bank an`, + `Die ${feedName}-Verbindung ist unterbrochen. Um Kartenimporte wiederherzustellen, melden Sie sich bei Ihrer Bank an`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `die Plaid-Verbindung zu Ihrem Geschäftsbankkonto ist unterbrochen. Bitte verbinden Sie Ihr Bankkonto ${maskedAccountNumber} erneut, damit Sie Ihre Expensify-Karten weiterhin nutzen können.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8308,8 +8308,8 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, addShippingAddress: {title: 'Wir benötigen Ihre Versandadresse', subtitle: 'Gib eine Adresse an, um deine Expensify Card zu erhalten.', cta: 'Adresse hinzufügen'}, activateCard: {title: 'Aktiviere deine Expensify Card', subtitle: 'Bestätige deine Karte und beginne mit dem Ausgeben.', cta: 'Aktivieren'}, reviewCardFraud: { - title: 'Verdächtige Aktivitäten auf Ihrer Expensify Card überprüfen', - titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Überprüfe ${amount} an potenziellem Betrug bei ${merchant}`, + title: 'Möglichen Betrug mit Ihrer Expensify Card überprüfen', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Überprüfen Sie potenziellen Betrug in Höhe von ${amount} bei ${merchant}`, subtitle: 'Expensify Card', cta: 'Überprüfen', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e2a204e16a4bd..9626c0bc5933b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7252,7 +7252,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin addedConnection: ({connectionName}: ConnectionNameParams) => `connecté à ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'a quitté la discussion', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `La connexion ${feedName} est interrompue. Pour rétablir l’importation des cartes, connectez-vous à votre banque`, + `La connexion ${feedName} est rompue. Pour rétablir l’importation des cartes, connectez-vous à votre banque`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `la connexion Plaid à votre compte bancaire professionnel est interrompue. Veuillez reconnecter votre compte bancaire ${maskedAccountNumber} pour continuer à utiliser vos cartes Expensify.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8314,7 +8314,7 @@ Voici un *reçu test* pour vous montrer comment cela fonctionne :`, addShippingAddress: {title: 'Nous avons besoin de votre adresse de livraison', subtitle: 'Indiquez une adresse pour recevoir votre carte Expensify.', cta: 'Ajouter une adresse'}, activateCard: {title: 'Activer votre carte Expensify', subtitle: 'Validez votre carte et commencez à dépenser.', cta: 'Activer'}, reviewCardFraud: { - title: 'Examiner un éventuel cas de fraude sur votre carte Expensify', + title: 'Examiner une fraude potentielle sur votre carte Expensify', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Examiner ${amount} de fraude potentielle chez ${merchant}`, subtitle: 'Carte Expensify', cta: 'Vérifier', diff --git a/src/languages/it.ts b/src/languages/it.ts index 87561d9ddde7f..95a5fbdf5827f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -772,7 +772,7 @@ const translations: TranslationDeepObject = { revoke: 'Revoca', title: 'Riconoscimento facciale/impronta digitale & passkey', explanation: - 'La verifica con volto/impronta o passkey è abilitata su uno o più dispositivi. La revoca dell’accesso richiederà un codice magico per la prossima verifica su qualsiasi dispositivo', + 'La verifica tramite volto/impronta digitale o passkey è abilitata su uno o più dispositivi. Revocare l’accesso richiederà un codice magico per la prossima verifica su qualsiasi dispositivo', confirmationPrompt: 'Sei sicuro? Avrai bisogno di un codice magico per la prossima verifica su qualsiasi dispositivo', cta: 'Revoca accesso', noDevices: 'Non hai alcun dispositivo registrato per la verifica con volto/impronta digitale o passkey. Se ne registri uno, potrai revocare tale accesso da qui.', @@ -8294,8 +8294,8 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, addShippingAddress: {title: 'Abbiamo bisogno del tuo indirizzo di spedizione', subtitle: 'Fornisci un indirizzo per ricevere la tua Expensify Card.', cta: 'Aggiungi indirizzo'}, activateCard: {title: 'Attiva la tua Expensify Card', subtitle: 'Convalida la tua carta e inizia a spendere.', cta: 'Attiva'}, reviewCardFraud: { - title: 'Esamina potenziali frodi sulla tua Expensify Card', - titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Esamina ${amount} di potenziale frode presso ${merchant}`, + title: 'Esamina una potenziale frode sulla tua Expensify Card', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Controlla ${amount} di potenziale frode presso ${merchant}`, subtitle: 'Carta Expensify', cta: 'Rivedi', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index ad8ae42dee509..5022c6dee1d07 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -770,9 +770,8 @@ const translations: TranslationDeepObject = { revoke: { revoke: '取り消す', title: '顔/指紋 & パスキー', - explanation: - '1 つ以上のデバイスで、顔 / 指紋またはパスキーによる認証が有効になっています。アクセスを取り消すと、次回どのデバイスであっても、認証にはマジックコードが必要になります', - confirmationPrompt: '本当に実行しますか?今後、どのデバイスでも次回の認証にはマジックコードが必要になります', + explanation: '1 台以上のデバイスで顔/指紋またはパスキーによる認証が有効になっています。アクセスを取り消すと、次回どのデバイスで認証する場合でもマジックコードが必要になります', + confirmationPrompt: '本当に続行しますか?今後どのデバイスでも次回の認証にはマジックコードが必要になります', cta: 'アクセスを取り消す', noDevices: '顔認証 / 指紋認証 またはパスキー認証用に登録されているデバイスがありません。 \nいずれかを登録すると、ここでそのアクセスを取り消せるようになります。', dismiss: '了解', @@ -7167,7 +7166,7 @@ ${reportName} addedConnection: ({connectionName}: ConnectionNameParams) => `${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]} に接続済み`, leftTheChat: 'チャットを退出しました', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `${feedName} との接続が切断されています。カードの取引明細の取り込みを再開するには、銀行にログインしてください`, + `${feedName} の接続が切断されています。カードの取込を再開するには、銀行にログインしてください`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `ビジネス銀行口座へのPlaid接続が切断されています。Expensifyカードを引き続きご利用いただくには、銀行口座 ${maskedAccountNumber} を再接続してください。`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8207,8 +8206,8 @@ Expensify の使い方をお見せするための*テストレシート*がこ activateCard: {title: 'Expensify Card を有効化', subtitle: 'カードを認証して、すぐに支出を始めましょう。', cta: '有効化'}, reviewCardFraud: { title: 'Expensify Card の不正利用の可能性を確認する', - titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `${merchant} における潜在的な不正行為の ${amount} を確認`, - subtitle: 'Expensify カード', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `${merchant} での不正の可能性がある ${amount} を確認`, + subtitle: 'Expensify Card', cta: 'レビュー', }, }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6636a8ad9226c..56534a11c4770 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -772,7 +772,7 @@ const translations: TranslationDeepObject = { revoke: 'Intrekken', title: 'Gezicht/vingerafdruk en passkeys', explanation: - 'Gezichts-/vingerafdruk- of passkeys-verificatie is ingeschakeld op één of meer apparaten. Toegang intrekken vereist een magische code voor de volgende verificatie op elk apparaat', + 'Gezichts-/vingerafdruk- of passkeys-verificatie is ingeschakeld op één of meer apparaten. Toegang intrekken betekent dat bij de volgende verificatie op elk apparaat een magische code vereist is', confirmationPrompt: 'Weet je het zeker? Je hebt een magische code nodig voor de volgende verificatie op elk apparaat', cta: 'Toegang intrekken', noDevices: 'Je hebt geen apparaten geregistreerd voor gezichts-/vingerafdruk- of passkey-verificatie. Als je er een registreert, kun je die toegang hier intrekken.', @@ -7212,7 +7212,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten addedConnection: ({connectionName}: ConnectionNameParams) => `verbonden met ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'heeft de chat verlaten', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `De ${feedName}-verbinding is verbroken. Om kaartimports te herstellen, log in bij uw bank`, + `De ${feedName}-verbinding is verbroken. Om kaartimports te herstellen, log in bij je bank`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `de Plaid-verbinding met uw zakelijke bankrekening is verbroken. Verbind uw bankrekening ${maskedAccountNumber} opnieuw om uw Expensify-kaarten te kunnen blijven gebruiken.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8273,7 +8273,7 @@ Hier is een *testbon* om je te laten zien hoe het werkt:`, title: 'Controleer mogelijke fraude op je Expensify Card', titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Controleer ${amount} aan mogelijke fraude bij ${merchant}`, subtitle: 'Expensify Card', - cta: 'Beoordelen', + cta: 'Beoordeling', }, }, }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 273180ab76820..bead3e6cf21aa 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -772,8 +772,8 @@ const translations: TranslationDeepObject = { revoke: 'Odwołaj', title: 'Rozpoznawanie twarzy/odcisk palca i klucze dostępu', explanation: - 'Weryfikacja za pomocą twarzy/odcisku palca lub klucza dostępu jest włączona na jednym lub większej liczbie urządzeń. Cofnięcie dostępu spowoduje, że przy następnej weryfikacji na dowolnym urządzeniu wymagany będzie magiczny kod', - confirmationPrompt: 'Czy na pewno? Będziesz potrzebować magicznego kodu do następnej weryfikacji na dowolnym urządzeniu', + 'Weryfikacja twarzą/odciskiem palca lub kluczem dostępowa jest włączona na jednym lub większej liczbie urządzeń. Odwołanie dostępu spowoduje, że przy następnej weryfikacji na dowolnym urządzeniu wymagany będzie magiczny kod', + confirmationPrompt: 'Czy na pewno? Będziesz potrzebować kodu magicznego do kolejnej weryfikacji na dowolnym urządzeniu', cta: 'Cofnij dostęp', noDevices: 'Nie masz żadnych urządzeń zarejestrowanych do weryfikacji twarzą, odciskiem palca ani kluczem dostępu. Jeśli jakieś zarejestrujesz, będziesz mógł/mogła cofnąć ten dostęp w tym miejscu.', @@ -8255,7 +8255,7 @@ Oto *paragon testowy*, który pokazuje, jak to działa:`, activateCard: {title: 'Aktywuj swoją kartę Expensify', subtitle: 'Zweryfikuj swoją kartę i zacznij wydawać.', cta: 'Aktywuj'}, reviewCardFraud: { title: 'Przejrzyj potencjalne oszustwo na swojej karcie Expensify', - titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Sprawdź ${amount} pod kątem potencjalnego oszustwa u ${merchant}`, + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `Sprawdź ${amount} pod kątem potencjalnego oszustwa w ${merchant}`, subtitle: 'Karta Expensify', cta: 'Przejrzyj', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 430b1dbb06c0d..3c8cb34cbf322 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -771,7 +771,7 @@ const translations: TranslationDeepObject = { revoke: 'Revogar', title: 'Rosto/digital & chaves de acesso', explanation: - 'A verificação por rosto/digital ou por chave de acesso está ativada em um ou mais dispositivos. Revogar o acesso exigirá um código mágico para a próxima verificação em qualquer dispositivo', + 'A verificação por rosto/digital ou chave de acesso está ativada em um ou mais dispositivos. Revogar o acesso exigirá um código mágico para a próxima verificação em qualquer dispositivo', confirmationPrompt: 'Tem certeza? Você precisará de um código mágico para a próxima verificação em qualquer dispositivo', cta: 'Revogar acesso', noDevices: 'Você não tem nenhum dispositivo registrado para verificação por rosto/digital ou passkey. Se você registrar algum, poderá revogar esse acesso aqui.', @@ -7201,7 +7201,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe addedConnection: ({connectionName}: ConnectionNameParams) => `conectado a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'saiu do chat', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `A conexão ${feedName} está quebrada. Para restaurar as importações de cartão, faça login no seu banco`, + `A conexão com ${feedName} está quebrada. Para restaurar as importações de cartão, faça login no seu banco`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `a conexão Plaid com sua conta bancária empresarial está quebrada. Por favor, reconecte sua conta bancária ${maskedAccountNumber} para continuar usando seus Cartões Expensify.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c99431e09d590..1b68279af855c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -766,8 +766,8 @@ const translations: TranslationDeepObject = { revoke: { revoke: '撤销', title: '面部识别/指纹识别与通行密钥', - explanation: '在一台或多台设备上已启用面部/指纹或通行密钥验证。撤销访问后,下次在任意设备上进行验证时都需要使用魔法代码', - confirmationPrompt: '您确定吗?您在任何设备上的下一次验证都需要一个魔法代码', + explanation: '在一台或多台设备上已启用面容/指纹或通行密钥验证。撤销访问权限后,下次在任意设备上进行验证时都需要使用魔法验证码', + confirmationPrompt: '你确定吗?接下来在任何设备上进行验证时,你都需要一个魔法验证码', cta: '撤销访问权限', noDevices: '您尚未注册任何用于人脸/指纹或通行密钥验证的设备。如果您注册了设备,您将可以在此撤销其访问权限。', dismiss: '明白了', @@ -7040,7 +7040,7 @@ ${reportName} addedConnection: ({connectionName}: ConnectionNameParams) => `已连接到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: '已离开聊天', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `${feedName} 连接已中断。若要恢复卡片导入,请登录您的银行账户`, + `${feedName} 连接已中断。若要恢复导入卡片交易,请登录您的银行账户`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `您的企业银行账户的 Plaid 连接已中断。请重新连接您的银行账户 ${maskedAccountNumber},以便继续使用您的 Expensify 卡。`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => @@ -8022,8 +8022,8 @@ ${reportName} addShippingAddress: {title: '我们需要您的收货地址', subtitle: '请提供一个地址以接收您的 Expensify Card。', cta: '添加地址'}, activateCard: {title: '激活您的 Expensify Card', subtitle: '验证您的卡片并开始消费。', cta: '激活'}, reviewCardFraud: { - title: '查看您的 Expensify Card 上的潜在欺诈行为', - titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `在 ${merchant} 审核可能存在欺诈的金额 ${amount}`, + title: '审查您的 Expensify Card 潜在欺诈行为', + titleWithDetails: ({amount, merchant}: {amount: string; merchant: string}) => `在 ${merchant} 发现疑似欺诈金额 ${amount},请审核`, subtitle: 'Expensify Card', cta: '审核', }, diff --git a/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx b/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx index 67aff5ddaae14..7b5a43d5acefe 100644 --- a/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx +++ b/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx @@ -3,9 +3,9 @@ import ExpensifyCardIcon from '@assets/images/expensify-card-icon.svg'; import BaseWidgetItem from '@components/BaseWidgetItem'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useTheme from '@hooks/useTheme'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; -import colors from '@styles/theme/colors'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -18,6 +18,7 @@ type ReviewCardFraudProps = { }; function ReviewCardFraud({card}: ReviewCardFraudProps) { + const theme = useTheme(); const {translate} = useLocalize(); // Get the fraud alert report ID and action ID for deeplink navigation @@ -76,8 +77,8 @@ function ReviewCardFraud({card}: ReviewCardFraudProps) { return ( Date: Fri, 30 Jan 2026 15:22:13 -0800 Subject: [PATCH 7/9] Revert languages --- src/languages/de.ts | 2 +- src/languages/fr.ts | 4 ++-- src/languages/it.ts | 6 +++--- src/languages/ja.ts | 6 +++--- src/languages/nl.ts | 4 ++-- src/languages/pl.ts | 6 +++--- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 6 +++--- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 22442d426e367..06acbd84f636e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -771,7 +771,7 @@ const translations: TranslationDeepObject = { revoke: 'Widerrufen', title: 'Gesicht/Fingerabdruck & Passkeys', explanation: - 'Die Gesichts-/Fingerabdruck- oder Passkey-Verifizierung ist auf einem oder mehreren Geräten aktiviert. Das Widerrufen des Zugriffs erfordert für die nächste Verifizierung auf jedem Gerät einen Magic Code', + 'Die Gesichts-/Fingerabdruck- oder Passkey-Verifizierung ist auf einem oder mehreren Geräten aktiviert. Durch das Widerrufen des Zugriffs wird für die nächste Verifizierung auf jedem Gerät ein magischer Code erforderlich', confirmationPrompt: 'Bist du sicher? Du benötigst einen magischen Code für die nächste Verifizierung auf jedem Gerät', cta: 'Zugriff widerrufen', noDevices: 'Du hast keine Geräte für Gesichts-/Fingerabdruck- oder Passkey-Verifizierung registriert. Wenn du welche registrierst, kannst du den Zugriff hier widerrufen.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 9626c0bc5933b..7176217dc8fff 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -774,7 +774,7 @@ const translations: TranslationDeepObject = { revoke: 'Révoquer', title: 'Reconnaissance faciale/empreinte digitale et passkeys', explanation: - 'La vérification par reconnaissance faciale/empreinte digitale ou par passkey est activée sur un ou plusieurs appareils. La révocation de l’accès nécessitera un code magique pour la prochaine vérification sur n’importe quel appareil', + 'La vérification par reconnaissance faciale/empreinte digitale ou par passkey est activée sur un ou plusieurs appareils. Révoquer l’accès exigera un code magique pour la prochaine vérification sur n’importe quel appareil', confirmationPrompt: 'Êtes-vous sûr ? Vous aurez besoin d’un code magique pour la prochaine vérification sur n’importe quel appareil', cta: 'Révoquer l’accès', noDevices: @@ -7252,7 +7252,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin addedConnection: ({connectionName}: ConnectionNameParams) => `connecté à ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'a quitté la discussion', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `La connexion ${feedName} est rompue. Pour rétablir l’importation des cartes, connectez-vous à votre banque`, + `La connexion à ${feedName} est rompue. Pour rétablir l'importation des cartes, connectez-vous à votre banque`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `la connexion Plaid à votre compte bancaire professionnel est interrompue. Veuillez reconnecter votre compte bancaire ${maskedAccountNumber} pour continuer à utiliser vos cartes Expensify.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => diff --git a/src/languages/it.ts b/src/languages/it.ts index 95a5fbdf5827f..ca39cf38f1ec6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -772,8 +772,8 @@ const translations: TranslationDeepObject = { revoke: 'Revoca', title: 'Riconoscimento facciale/impronta digitale & passkey', explanation: - 'La verifica tramite volto/impronta digitale o passkey è abilitata su uno o più dispositivi. Revocare l’accesso richiederà un codice magico per la prossima verifica su qualsiasi dispositivo', - confirmationPrompt: 'Sei sicuro? Avrai bisogno di un codice magico per la prossima verifica su qualsiasi dispositivo', + 'La verifica con volto/impronta digitale o passkey è abilitata su uno o più dispositivi. La revoca dell’accesso richiederà un codice magico per la prossima verifica su qualsiasi dispositivo', + confirmationPrompt: 'Sei sicuro? Ti servirà un codice magico per la prossima verifica su qualsiasi dispositivo', cta: 'Revoca accesso', noDevices: 'Non hai alcun dispositivo registrato per la verifica con volto/impronta digitale o passkey. Se ne registri uno, potrai revocare tale accesso da qui.', dismiss: 'Ho capito', @@ -7229,7 +7229,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori addedConnection: ({connectionName}: ConnectionNameParams) => `connesso a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'ha lasciato la chat', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `La connessione ${feedName} è interrotta. Per ripristinare l’importazione delle carte, accedi alla tua banca`, + `La connessione ${feedName} non funziona. Per ripristinare le importazioni delle carte, accedi alla tua banca`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `la connessione Plaid al tuo conto bancario aziendale è interrotta. Ricollega il tuo conto bancario ${maskedAccountNumber} per continuare a usare le tue carte Expensify.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 5022c6dee1d07..d384254a1b6e0 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -770,8 +770,8 @@ const translations: TranslationDeepObject = { revoke: { revoke: '取り消す', title: '顔/指紋 & パスキー', - explanation: '1 台以上のデバイスで顔/指紋またはパスキーによる認証が有効になっています。アクセスを取り消すと、次回どのデバイスで認証する場合でもマジックコードが必要になります', - confirmationPrompt: '本当に続行しますか?今後どのデバイスでも次回の認証にはマジックコードが必要になります', + explanation: '1 台以上のデバイスで顔 / 指紋またはパスキー認証が有効になっています。アクセスを取り消すと、今後どのデバイスでも次回の認証時にマジックコードが必要になります', + confirmationPrompt: '本当に実行しますか?次回、どのデバイスで確認する場合でも、マジックコードが必要になります', cta: 'アクセスを取り消す', noDevices: '顔認証 / 指紋認証 またはパスキー認証用に登録されているデバイスがありません。 \nいずれかを登録すると、ここでそのアクセスを取り消せるようになります。', dismiss: '了解', @@ -7166,7 +7166,7 @@ ${reportName} addedConnection: ({connectionName}: ConnectionNameParams) => `${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]} に接続済み`, leftTheChat: 'チャットを退出しました', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `${feedName} の接続が切断されています。カードの取込を再開するには、銀行にログインしてください`, + `${feedName} との接続が切断されています。カードの取引明細の取り込みを再開するには、銀行にログインしてください`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `ビジネス銀行口座へのPlaid接続が切断されています。Expensifyカードを引き続きご利用いただくには、銀行口座 ${maskedAccountNumber} を再接続してください。`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 56534a11c4770..89e9b908e1852 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -772,7 +772,7 @@ const translations: TranslationDeepObject = { revoke: 'Intrekken', title: 'Gezicht/vingerafdruk en passkeys', explanation: - 'Gezichts-/vingerafdruk- of passkeys-verificatie is ingeschakeld op één of meer apparaten. Toegang intrekken betekent dat bij de volgende verificatie op elk apparaat een magische code vereist is', + 'Gezichts-/vingerafdruk- of passkeys-verificatie is ingeschakeld op één of meer apparaten. Toegang intrekken vereist een magische code voor de volgende verificatie op elk apparaat', confirmationPrompt: 'Weet je het zeker? Je hebt een magische code nodig voor de volgende verificatie op elk apparaat', cta: 'Toegang intrekken', noDevices: 'Je hebt geen apparaten geregistreerd voor gezichts-/vingerafdruk- of passkey-verificatie. Als je er een registreert, kun je die toegang hier intrekken.', @@ -7212,7 +7212,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten addedConnection: ({connectionName}: ConnectionNameParams) => `verbonden met ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'heeft de chat verlaten', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `De ${feedName}-verbinding is verbroken. Om kaartimports te herstellen, log in bij je bank`, + `De ${feedName}-verbinding is verbroken. Om kaartimporten te herstellen, log in bij uw bank`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `de Plaid-verbinding met uw zakelijke bankrekening is verbroken. Verbind uw bankrekening ${maskedAccountNumber} opnieuw om uw Expensify-kaarten te kunnen blijven gebruiken.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => diff --git a/src/languages/pl.ts b/src/languages/pl.ts index bead3e6cf21aa..4ccf2269228a1 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -769,11 +769,11 @@ const translations: TranslationDeepObject = { biometrics: 'Włącz szybką i bezpieczną weryfikację za pomocą twarzy lub odcisku palca. Bez haseł ani kodów.', }, revoke: { - revoke: 'Odwołaj', + revoke: 'Unieważnij', title: 'Rozpoznawanie twarzy/odcisk palca i klucze dostępu', explanation: - 'Weryfikacja twarzą/odciskiem palca lub kluczem dostępowa jest włączona na jednym lub większej liczbie urządzeń. Odwołanie dostępu spowoduje, że przy następnej weryfikacji na dowolnym urządzeniu wymagany będzie magiczny kod', - confirmationPrompt: 'Czy na pewno? Będziesz potrzebować kodu magicznego do kolejnej weryfikacji na dowolnym urządzeniu', + 'Weryfikacja za pomocą twarzy/odcisku palca lub klucza dostępu (passkey) jest włączona na jednym lub większej liczbie urządzeń. Cofnięcie dostępu spowoduje, że przy następnej weryfikacji na dowolnym urządzeniu wymagany będzie magiczny kod', + confirmationPrompt: 'Czy na pewno? Będziesz potrzebować magicznego kodu do następnej weryfikacji na dowolnym urządzeniu', cta: 'Cofnij dostęp', noDevices: 'Nie masz żadnych urządzeń zarejestrowanych do weryfikacji twarzą, odciskiem palca ani kluczem dostępu. Jeśli jakieś zarejestrujesz, będziesz mógł/mogła cofnąć ten dostęp w tym miejscu.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 3c8cb34cbf322..14144144354b1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7201,7 +7201,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe addedConnection: ({connectionName}: ConnectionNameParams) => `conectado a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: 'saiu do chat', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `A conexão com ${feedName} está quebrada. Para restaurar as importações de cartão, faça login no seu banco`, + `A conexão ${feedName} está quebrada. Para restaurar as importações do cartão, faça login no seu banco`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `a conexão Plaid com sua conta bancária empresarial está quebrada. Por favor, reconecte sua conta bancária ${maskedAccountNumber} para continuar usando seus Cartões Expensify.`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 1b68279af855c..9c9513d34e339 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -766,8 +766,8 @@ const translations: TranslationDeepObject = { revoke: { revoke: '撤销', title: '面部识别/指纹识别与通行密钥', - explanation: '在一台或多台设备上已启用面容/指纹或通行密钥验证。撤销访问权限后,下次在任意设备上进行验证时都需要使用魔法验证码', - confirmationPrompt: '你确定吗?接下来在任何设备上进行验证时,你都需要一个魔法验证码', + explanation: '在一台或多台设备上已启用面部 / 指纹或通行密钥验证。撤销访问权限后,下次在任何设备上进行验证时都需要使用魔法验证码', + confirmationPrompt: '你确定吗?在任何设备上进行下一步验证时,你都需要一个魔法代码', cta: '撤销访问权限', noDevices: '您尚未注册任何用于人脸/指纹或通行密钥验证的设备。如果您注册了设备,您将可以在此撤销其访问权限。', dismiss: '明白了', @@ -7040,7 +7040,7 @@ ${reportName} addedConnection: ({connectionName}: ConnectionNameParams) => `已连接到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, leftTheChat: '已离开聊天', companyCardConnectionBroken: ({feedName, workspaceCompanyCardRoute}: {feedName: string; workspaceCompanyCardRoute: string}) => - `${feedName} 连接已中断。若要恢复导入卡片交易,请登录您的银行账户`, + `${feedName} 连接已中断。要恢复卡片导入,请登录到您的银行`, plaidBalanceFailure: ({maskedAccountNumber, walletRoute}: {maskedAccountNumber: string; walletRoute: string}) => `您的企业银行账户的 Plaid 连接已中断。请重新连接您的银行账户 ${maskedAccountNumber},以便继续使用您的 Expensify 卡。`, settlementAccountLocked: ({maskedBankAccountNumber}: OriginalMessageSettlementAccountLocked, linkURL: string) => From a0b9d8c206a58c3e767d35f7791eb1a93b488684 Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Fri, 30 Jan 2026 15:27:39 -0800 Subject: [PATCH 8/9] Fix the styles --- .../home/TimeSensitiveSection/items/ReviewCardFraud.tsx | 7 +++---- src/selectors/Card.ts | 5 ++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx b/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx index 7b5a43d5acefe..67aff5ddaae14 100644 --- a/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx +++ b/src/pages/home/TimeSensitiveSection/items/ReviewCardFraud.tsx @@ -3,9 +3,9 @@ import ExpensifyCardIcon from '@assets/images/expensify-card-icon.svg'; import BaseWidgetItem from '@components/BaseWidgetItem'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useTheme from '@hooks/useTheme'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; +import colors from '@styles/theme/colors'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -18,7 +18,6 @@ type ReviewCardFraudProps = { }; function ReviewCardFraud({card}: ReviewCardFraudProps) { - const theme = useTheme(); const {translate} = useLocalize(); // Get the fraud alert report ID and action ID for deeplink navigation @@ -77,8 +76,8 @@ function ReviewCardFraud({card}: ReviewCardFraudProps) { return ( ): TimeSensitiveCa } // Only consider Expensify cards - const isExpensifyCard = card.bank === CONST.EXPENSIFY_CARD.BANK; - if (!isExpensifyCard) { + if (!isExpensifyCard(card)) { continue; } From dbe78b656e669645464f7d1cf73f6d73a1690fd1 Mon Sep 17 00:00:00 2001 From: Vit Horacek Date: Fri, 30 Jan 2026 15:37:44 -0800 Subject: [PATCH 9/9] Use triggerAmount --- src/components/BaseWidgetItem.tsx | 12 ++++- .../items/ReviewCardFraud.tsx | 46 ++++--------------- src/types/onyx/Card.ts | 6 +++ 3 files changed, 24 insertions(+), 40 deletions(-) diff --git a/src/components/BaseWidgetItem.tsx b/src/components/BaseWidgetItem.tsx index 344beb6d5d645..35fabbf15e8e9 100644 --- a/src/components/BaseWidgetItem.tsx +++ b/src/components/BaseWidgetItem.tsx @@ -5,6 +5,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import type IconAsset from '@src/types/utils/IconAsset'; import Button from './Button'; +import type {ButtonProps} from './Button'; import Icon from './Icon'; import Text from './Text'; @@ -31,12 +32,18 @@ type BaseWidgetItemProps = { /** Optional: fill color for the icon (defaults to white) */ iconFill?: string; + + /** Optional: additional props to pass to the Button component (e.g., danger, success) */ + buttonProps?: Partial>; }; -function BaseWidgetItem({icon, iconBackgroundColor, title, subtitle, ctaText, onCtaPress, iconFill}: BaseWidgetItemProps) { +function BaseWidgetItem({icon, iconBackgroundColor, title, subtitle, ctaText, onCtaPress, iconFill, buttonProps}: BaseWidgetItemProps) { const styles = useThemeStyles(); const theme = useTheme(); + // Default to success style unless buttonProps specifies otherwise + const showSuccess = buttonProps?.success ?? !buttonProps?.danger; + return ( @@ -54,7 +61,8 @@ function BaseWidgetItem({icon, iconBackgroundColor, title, subtitle, ctaText, on