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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 51 additions & 12 deletions src/components/PlaidCardFeedIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from 'react';
import {View} from 'react-native';
import React, {useEffect, useState} from 'react';
import {ActivityIndicator, View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
import useTheme from '@hooks/useTheme';
import useThemeIllustrations from '@hooks/useThemeIllustrations';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import Icon from './Icon';
Expand All @@ -14,19 +16,56 @@ type PlaidCardFeedIconProps = {
};

function PlaidCardFeedIcon({plaidUrl, style, isLarge}: PlaidCardFeedIconProps) {
const [isBrokenImage, setIsBrokenImage] = useState<boolean>(false);
const styles = useThemeStyles();
const illustrations = useThemeIllustrations();
const theme = useTheme();
const width = isLarge ? variables.cardPreviewWidth : variables.cardIconWidth;
const height = isLarge ? variables.cardPreviewHeight : variables.cardIconHeight;
const [loading, setLoading] = useState<boolean>(true);

useEffect(() => {
if (!plaidUrl) {
return;
}
setIsBrokenImage(false);
setLoading(true);
}, [plaidUrl]);

return (
<View style={[style]}>
<Image
source={{uri: plaidUrl}}
style={isLarge ? styles.plaidIcon : styles.plaidIconSmall}
cachePolicy="memory-disk"
/>
<Icon
src={isLarge ? Illustrations.PlaidCompanyCardDetailLarge : Illustrations.PlaidCompanyCardDetail}
height={isLarge ? variables.cardPreviewHeight : variables.cardIconHeight}
width={isLarge ? variables.cardPreviewWidth : variables.cardIconWidth}
/>
{isBrokenImage ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you show a loading indicator until we know for sure the icon is available? Otherwise it would flicker like this:

Screen.Recording.2025-06-13.at.01.29.32-compressed.mov

cc @Expensify/design

TL;DR Some bank icons are not available and we decided to show the gray card icon fallback. But we still need some data fetching from the content provider to know if the icon is available or not. During that, we show the plain white card icon. And it will flicker a moment when we change from the white card icon to the gray card icon fallback.

But I think a loading indicator has better visual effect here. What do you think? If so what should this indicator look like?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm I feel like maybe we should make the fallback icon look more like the default "white" card then? this way there isn't really a transition from white to gray?

Or we could try a loading spinner, I would be open to seeing something like that too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah I'd be down to see a loading spinner.

<Icon
src={illustrations.GenericCompanyCardLarge}
height={height}
width={width}
additionalStyles={styles.cardIcon}
/>
) : (
<>
<Image
source={{uri: plaidUrl}}
style={isLarge ? styles.plaidIcon : styles.plaidIconSmall}
cachePolicy="memory-disk"
onError={() => setIsBrokenImage(true)}
onLoadEnd={() => setLoading(false)}
/>
{loading ? (
<View style={[styles.justifyContentCenter, {width, height}]}>
<ActivityIndicator
color={theme.spinner}
size={20}
/>
</View>
) : (
<Icon
src={isLarge ? Illustrations.PlaidCompanyCardDetailLarge : Illustrations.PlaidCompanyCardDetail}
height={height}
width={width}
/>
)}
</>
)}
</View>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4000,7 +4000,7 @@ const translations = {
companyCard: 'company card',
chooseCardFeed: 'Choose card feed',
ukRegulation:
'Expensify Limited is an agent of Plaid Financial Ltd., an authorised payment institution regulated by the Financial Conduct Authority under the Payment Services Regulations 2017 (Firm Reference Number: 804718). Plaid provides you with regulated account information services through Expensify Limited as its agent.',
'Expensify, Inc. is an agent of Plaid Financial Ltd., an authorised payment institution regulated by the Financial Conduct Authority under the Payment Services Regulations 2017 (Firm Reference Number: 804718). Plaid provides you with regulated account information services through Expensify Limited as its agent.',
},
expensifyCard: {
issueAndManageCards: 'Issue and manage your Expensify Cards',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4040,7 +4040,7 @@ const translations = {
companyCard: 'tarjeta de empresa',
chooseCardFeed: 'Elige feed de tarjetas',
ukRegulation:
'Expensify Limited es un agente de Plaid Financial Ltd., una institución de pago autorizada y regulada por la Financial Conduct Authority conforme al Reglamento de Servicios de Pago de 2017 (Número de Referencia de la Firma: 804718). Plaid te proporciona servicios regulados de información de cuentas a través de Expensify Limited como su agente.',
'Expensify, Inc. es un agente de Plaid Financial Ltd., una institución de pago autorizada y regulada por la Financial Conduct Authority conforme al Reglamento de Servicios de Pago de 2017 (Número de Referencia de la Firma: 804718). Plaid te proporciona servicios regulados de información de cuentas a través de Expensify Limited como su agente.',
},
expensifyCard: {
issueAndManageCards: 'Emitir y gestionar Tarjetas Expensify',
Expand Down
25 changes: 24 additions & 1 deletion src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type {OnyxValues} from '@src/ONYXKEYS';
import ONYXKEYS from '@src/ONYXKEYS';
import type {BankAccountList, Card, CardFeeds, CardList, CompanyCardFeed, ExpensifyCardSettings, PersonalDetailsList, Policy, WorkspaceCardsList} from '@src/types/onyx';
import type {BankAccountList, Card, CardFeeds, CardList, CompanyCardFeed, CurrencyList, ExpensifyCardSettings, PersonalDetailsList, Policy, WorkspaceCardsList} from '@src/types/onyx';
import type {FilteredCardList} from '@src/types/onyx/Card';
import type {CardFeedData, CompanyCardFeedWithNumber, CompanyCardNicknames, CompanyFeeds, DirectCardFeedData} from '@src/types/onyx/CardFeeds';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
Expand Down Expand Up @@ -432,10 +432,31 @@ function getPlaidInstitutionId(feedName?: string) {
return feed.at(1);
}

function isPlaidSupportedCountry(selectedCountry?: string) {
if (!selectedCountry) {
return false;
}
return CONST.PLAID_SUPPORT_COUNTRIES.includes(selectedCountry);
}

function getDomainOrWorkspaceAccountID(workspaceAccountID: number, cardFeedData: CardFeedData | undefined): number {
return cardFeedData?.domainID ?? workspaceAccountID;
}

function getPlaidCountry(outputCurrency?: string, currencyList?: CurrencyList, countryByIp?: string) {
const selectedCurrency = outputCurrency ? currencyList?.[outputCurrency] : null;
const countries = selectedCurrency?.countries;

if (outputCurrency === CONST.CURRENCY.EUR) {
if (countryByIp && countries?.includes(countryByIp)) {
return countryByIp;
}
return '';
}
const country = countries?.[0];
return country ?? '';
}

// We will simplify the logic below once we have #50450 #50451 implemented
const getCorrectStepForSelectedBank = (selectedBank: ValueOf<typeof CONST.COMPANY_CARDS.BANKS>) => {
const banksWithFeedType = [
Expand Down Expand Up @@ -693,8 +714,10 @@ export {
getBankCardDetailsImage,
getSelectedFeed,
getCorrectStepForSelectedBank,
getPlaidCountry,
getCustomOrFormattedFeedName,
isCardClosed,
isPlaidSupportedCountry,
getFilteredCardList,
hasOnlyOneCardToAssign,
checkIfNewFeedConnected,
Expand Down
6 changes: 4 additions & 2 deletions src/libs/actions/getCompanyCardBankConnection/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type CompanyCardPlaidConnection = {
domainName: string;
feedName: string;
feed: string;
country: string;
};

function getCompanyCardBankConnection(policyID?: string, bankName?: string) {
Expand Down Expand Up @@ -47,8 +48,8 @@ function getCompanyCardBankConnection(policyID?: string, bankName?: string) {
return `${commandURL}partners/banks/${bank}/oauth_callback.php?${new URLSearchParams(params).toString()}`;
}

function getCompanyCardPlaidConnection(policyID?: string, publicToken?: string, feed?: string, feedName?: string) {
if (!policyID || !publicToken || !feed || !feedName) {
function getCompanyCardPlaidConnection(policyID?: string, publicToken?: string, feed?: string, feedName?: string, country?: string) {
if (!policyID || !publicToken || !feed || !feedName || !country) {
return null;
}
const authToken = NetworkStore.getAuthToken();
Expand All @@ -57,6 +58,7 @@ function getCompanyCardPlaidConnection(policyID?: string, publicToken?: string,
feed,
feedName,
publicToken,
country,
domainName: PolicyUtils.getDomainNameForPolicy(policyID),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ function BankConnection({policyID: policyIDFromProps, feed, route}: BankConnecti
const plaidToken = addNewCard?.data?.publicToken ?? assignCard?.data?.plaidAccessToken;
const plaidFeed = addNewCard?.data?.plaidConnectedFeed ?? assignCard?.data?.institutionId;
const plaidFeedName = addNewCard?.data?.plaidConnectedFeedName ?? assignCard?.data?.plaidConnectedFeedName;
const country = addNewCard?.data?.selectedCountry;

const url =
isBetaEnabled(CONST.BETAS.PLAID_COMPANY_CARDS) && plaidToken
? getCompanyCardPlaidConnection(policyID, plaidToken, plaidFeed, plaidFeedName)
? getCompanyCardPlaidConnection(policyID, plaidToken, plaidFeed, plaidFeedName, country)
: getCompanyCardBankConnection(policyID, bankName);
const [cardFeeds] = useCardFeeds(policyID);
const [isConnectionCompleted, setConnectionCompleted] = useState(false);
Expand Down Expand Up @@ -115,9 +117,9 @@ function BankConnection({policyID: policyIDFromProps, feed, route}: BankConnecti
if (newFeed) {
updateSelectedFeed(newFeed, policyID);
}
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID));
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID), {forceReplace: true});
}
}, [isNewFeedConnected, newFeed, policyID, url, feed, isFeedExpired, assignCard]);
}, [isNewFeedConnected, newFeed, policyID, url, feed, isFeedExpired, assignCard?.data?.dateOption]);

const checkIfConnectionCompleted = (navState: WebViewNavigation) => {
if (!navState.url.includes(ROUTES.BANK_CONNECTION_COMPLETE)) {
Expand Down
7 changes: 4 additions & 3 deletions src/pages/workspace/companyCards/BankConnection/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ function BankConnection({policyID: policyIDFromProps, feed, route}: BankConnecti
const plaidToken = addNewCard?.data?.publicToken ?? assignCard?.data?.plaidAccessToken;
const plaidFeed = addNewCard?.data?.plaidConnectedFeed ?? assignCard?.data?.institutionId;
const plaidFeedName = addNewCard?.data?.plaidConnectedFeedName ?? assignCard?.data?.plaidConnectedFeedName;
const country = addNewCard?.data?.selectedCountry;
const {isBetaEnabled} = usePermissions();

const url =
isBetaEnabled(CONST.BETAS.PLAID_COMPANY_CARDS) && plaidToken
? getCompanyCardPlaidConnection(policyID, plaidToken, plaidFeed, plaidFeedName)
? getCompanyCardPlaidConnection(policyID, plaidToken, plaidFeed, plaidFeedName, country)
: getCompanyCardBankConnection(policyID, bankName);
const isFeedExpired = feed ? isSelectedFeedExpired(cardFeeds?.settings?.oAuthAccountDetails?.[feed]) : false;
const headerTitleAddCards = !backTo ? translate('workspace.companyCards.addCards') : undefined;
Expand Down Expand Up @@ -140,13 +141,13 @@ function BankConnection({policyID: policyIDFromProps, feed, route}: BankConnecti
updateSelectedFeed(newFeed, policyID);
}
Navigation.closeRHPFlow();
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID));
Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID), {forceReplace: true});
return;
}
if (!shouldBlockWindowOpen) {
customWindow = openBankConnection(url);
}
}, [isNewFeedConnected, shouldBlockWindowOpen, newFeed, policyID, url, feed, isFeedExpired, isOffline, assignCard]);
}, [isNewFeedConnected, shouldBlockWindowOpen, newFeed, policyID, url, feed, isFeedExpired, isOffline, assignCard?.data?.dateOption]);

return (
<ScreenWrapper
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useCardFeeds from '@hooks/useCardFeeds';
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
Expand All @@ -25,12 +26,13 @@ import {
getCompanyFeeds,
getCustomOrFormattedFeedName,
getDomainOrWorkspaceAccountID,
getPlaidCountry,
getPlaidInstitutionIconUrl,
getPlaidInstitutionId,
isCustomFeed,
} from '@libs/CardUtils';
import Navigation from '@navigation/Navigation';
import {setAssignCardStepAndData} from '@userActions/CompanyCards';
import {setAddNewCompanyCardStepAndData, setAssignCardStepAndData} from '@userActions/CompanyCards';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
Expand Down Expand Up @@ -59,7 +61,10 @@ function WorkspaceCompanyCardsListHeaderButtons({policyID, selectedFeed, shouldS
const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout();
const workspaceAccountID = useWorkspaceAccountID(policyID);
const [cardFeeds] = useCardFeeds(policyID);
const policy = usePolicy(policyID);
const [allFeedsCards] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`, {canBeMissing: false});
const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST, {canBeMissing: true});
const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY, {canBeMissing: false});
const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout;
const formattedFeedName = getCustomOrFormattedFeedName(selectedFeed, cardFeeds?.settings?.companyCardNicknames);
const isCommercialFeed = isCustomFeed(selectedFeed);
Expand All @@ -74,7 +79,15 @@ function WorkspaceCompanyCardsListHeaderButtons({policyID, selectedFeed, shouldS
const openBankConnection = () => {
const institutionId = !!getPlaidInstitutionId(selectedFeed);
if (institutionId) {
setAssignCardStepAndData({currentStep: CONST.COMPANY_CARD.STEP.PLAID_CONNECTION});
const country = getPlaidCountry(policy?.outputCurrency, currencyList, countryByIp);
setAddNewCompanyCardStepAndData({
data: {
selectedCountry: country,
},
});
setAssignCardStepAndData({
currentStep: CONST.COMPANY_CARD.STEP.PLAID_CONNECTION,
});
Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed)));
return;
}
Expand Down
13 changes: 12 additions & 1 deletion src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getCompanyFeeds,
getDomainOrWorkspaceAccountID,
getFilteredCardList,
getPlaidCountry,
getPlaidInstitutionId,
getSelectedFeed,
hasOnlyOneCardToAssign,
Expand All @@ -30,7 +31,7 @@ import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils';
import {isDeletedPolicyEmployee} from '@libs/PolicyUtils';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections';
import {openPolicyCompanyCardsFeed, openPolicyCompanyCardsPage, setAssignCardStepAndData} from '@userActions/CompanyCards';
import {clearAddNewCardFlow, openPolicyCompanyCardsFeed, openPolicyCompanyCardsPage, setAddNewCompanyCardStepAndData, setAssignCardStepAndData} from '@userActions/CompanyCards';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
Expand All @@ -56,6 +57,7 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) {
const selectedFeed = getSelectedFeed(lastSelectedFeed, cardFeeds);
const [cardsList] = useCardsList(policyID, selectedFeed);
const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY, {canBeMissing: false});
const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST, {canBeMissing: true});
const {isBetaEnabled} = usePermissions();
const hasNoAssignedCard = Object.keys(cardsList ?? {}).length === 0;

Expand Down Expand Up @@ -139,9 +141,18 @@ function WorkspaceCompanyCardsPage({route}: WorkspaceCompanyCardsPageProps) {

if (isFeedExpired) {
const institutionId = !!getPlaidInstitutionId(selectedFeed);
if (institutionId) {
const country = getPlaidCountry(policy?.outputCurrency, currencyList, countryByIp);
setAddNewCompanyCardStepAndData({
data: {
selectedCountry: country,
},
});
}
currentStep = institutionId ? CONST.COMPANY_CARD.STEP.PLAID_CONNECTION : CONST.COMPANY_CARD.STEP.BANK_CONNECTION;
}

clearAddNewCardFlow();
setAssignCardStepAndData({data, currentStep});
Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD.getRoute(policyID, selectedFeed)));
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID';
import BankConnection from '@pages/workspace/companyCards/BankConnection';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import {openPolicyAddCardFeedPage} from '@userActions/CompanyCards';
import {clearAddNewCardFlow, openPolicyAddCardFeedPage} from '@userActions/CompanyCards';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
Expand All @@ -33,6 +33,12 @@ function AddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) {

const isAddCardFeedLoading = isLoadingOnyxValue(addNewCardFeedMetadata);

useEffect(() => {
return () => {
clearAddNewCardFlow();
};
}, []);

useEffect(() => {
// If the user only has a domain feed, a workspace account may not have been created yet.
// However, adding a workspace feed requires a workspace account.
Expand Down
6 changes: 6 additions & 0 deletions src/pages/workspace/companyCards/addNew/CardTypeStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
import {isPlaidSupportedCountry} from '@libs/CardUtils';
import variables from '@styles/variables';
import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -93,6 +94,7 @@ function CardTypeStep() {
const {bankName, selectedBank, feedType} = addNewCard?.data ?? {};
const isOtherBankSelected = selectedBank === CONST.COMPANY_CARDS.BANKS.OTHER;
const isNewCardTypeSelected = typeSelected !== feedType;
const doesCountrySupportPlaid = isPlaidSupportedCountry(addNewCard?.data.selectedCountry);

const submit = () => {
if (!typeSelected) {
Expand All @@ -118,6 +120,10 @@ function CardTypeStep() {
setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK});
return;
}
if (isBetaEnabled(CONST.BETAS.PLAID_COMPANY_CARDS) && !doesCountrySupportPlaid) {
setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_COUNTRY});
return;
}
setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE});
};

Expand Down
Loading