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
29 changes: 17 additions & 12 deletions components/Card/NewCardDetails/CardActionsRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { router } from 'expo-router';
import { ShieldCheck } from 'lucide-react-native';

import CardDirectDepositModal from '@/components/Card/CardDirectDepositModal';
import RegisterSpendAction from '@/components/Card/NewCardDetails/RegisterSpendAction';
import WithdrawToCardModal from '@/components/Card/WithdrawToCardModal';
import { Text } from '@/components/ui/text';
import { path } from '@/constants/path';
Expand Down Expand Up @@ -80,6 +79,12 @@ interface CardActionsRowProps {
isFreezing: boolean;
onFreezeToggle: () => void;
onMorePress: () => void;
/**
* Opens the card-spending sheet. The sheet itself lives on the pane rather than in
* this row, because a blocked "Show details" tap opens the same one — and it has to
* stay reachable when this row hides its own button (a frozen card).
*/
onSpendPress: () => void;
/**
* Whether funds can move onto the card: not frozen, and KYC not paused or
* offboarded. Derived by the parent (`canAddFundsToCard`) rather than here, so
Expand Down Expand Up @@ -115,6 +120,7 @@ const CardActionsRow = ({
isFreezing,
onFreezeToggle,
onMorePress,
onSpendPress,
canAddFunds,
canWithdraw,
}: CardActionsRowProps) => {
Expand All @@ -136,17 +142,16 @@ const CardActionsRow = ({
<View className="flex-row items-start justify-center">
{showRegister && (
<View style={styles.item}>
<RegisterSpendAction
trigger={
<CircleAction label={isRevoked ? 'Paused' : isRegistered ? 'Spending' : 'Set up'}>
<Image
source={getAsset('images/card-action-add-funds.png')}
style={styles.actionIcon}
contentFit="contain"
/>
</CircleAction>
}
/>
<CircleAction
label={isRevoked ? 'Paused' : isRegistered ? 'Spending' : 'Set up'}
onPress={onSpendPress}
>
<Image
source={getAsset('images/card-action-add-funds.png')}
style={styles.actionIcon}
contentFit="contain"
/>
</CircleAction>
</View>
)}
{showDeposit && (
Expand Down
33 changes: 32 additions & 1 deletion components/Card/NewCardDetails/CardDetailsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,19 @@ import { getCardHeroDestination } from '@/components/Card/NewCardDetails/cardHer
import CardLinksList from '@/components/Card/NewCardDetails/CardLinksList';
import CardRevealSection from '@/components/Card/NewCardDetails/CardRevealSection';
import { HERO_ENTER, HeroEnter } from '@/components/Card/NewCardDetails/heroMotion';
import RegisterSpendAction from '@/components/Card/NewCardDetails/RegisterSpendAction';
import { usePageLeft } from '@/components/Navbar/Sidebar';
import CashbackDetailsSheet from '@/components/Rewards/NewRewards/CashbackDetailsSheet';
import { path } from '@/constants/path';
import { useCardDetails } from '@/hooks/useCardDetails';
import { useCardProvider } from '@/hooks/useCardProvider';
import { CardSpendRegistrationSource } from '@/hooks/useCardSpendRegistration';
import { useCardStatus } from '@/hooks/useCardStatus';
import { useCustomer } from '@/hooks/useCustomer';
import { useRewardsUserData } from '@/hooks/useRewards';
import { freezeCard, unfreezeCard } from '@/lib/api';
import { resolveUserCashbackRate } from '@/lib/tierCashback';
import { CardStatus } from '@/lib/types';
import { CardProvider, CardStatus } from '@/lib/types';
import {
canAddFundsToCard,
canToggleCardFreeze,
Expand Down Expand Up @@ -90,6 +92,19 @@ const CardDetailsPane = () => {
const { provider } = useCardProvider();
const [isFreezing, setIsFreezing] = useState(false);
const [isAddToWalletOpen, setIsAddToWalletOpen] = useState(false);
// The card-spending sheet is owned here rather than by the action row, because two
// things open it: the row's own "Set up"/"Spending" button, and a "Show details" tap
// on a card that cannot spend yet. One instance above both also keeps it reachable
// when the row hides its button (a frozen card) — which is exactly when a blocked
// reveal still needs somewhere to send the user. `null` is closed; the value it holds
// is which entry point opened it, for the registration funnel.
const [spendSheetSource, setSpendSheetSource] = useState<CardSpendRegistrationSource | null>(
null,
);
// Stable identities: the reveal section folds its opener into the memoised toggle
// handler, which would be rebuilt on every render of this pane otherwise.
const openSpendSheet = useCallback(() => setSpendSheetSource('spending_sheet'), []);
const openSpendSheetFromReveal = useCallback(() => setSpendSheetSource('card_reveal'), []);
// Held true through the dismissal, so the sections have something to animate out
// of; without it `isOpen` going false would yank the pane off screen instantly.
const [isSettling, setIsSettling] = useState(false);
Expand All @@ -114,6 +129,10 @@ const CardDetailsPane = () => {
scrollRef.current?.scrollTo({ y: 0, animated: false });
return;
}
// A dismissed pane must not leave its spending sheet floating over the wallet: the
// dialog is portalled, so it outlives the layer that opened it. Clearing the source
// rather than merely hiding it also stops the sheet reappearing on the next visit.
setSpendSheetSource(null);
// Nothing to settle if it was never opened, or the pane would sit visible for the
// settle window on startup.
if (!hasOpened.current) return;
Expand Down Expand Up @@ -225,6 +244,7 @@ const CardDetailsPane = () => {
last4={cardDetails?.card_details?.last_4}
cardholderName={cardDetails?.cardholder_name}
provider={provider}
onRequireSpendSetup={openSpendSheetFromReveal}
// The card's own issuing country, falling back to the KYC residence
// country the status endpoint reports (it's absent for test overrides).
issuingCountryCode={cardDetails?.issuing_country ?? cardStatus?.country}
Expand All @@ -236,6 +256,7 @@ const CardDetailsPane = () => {
isFreezing={isFreezing}
onFreezeToggle={handleFreezeToggle}
onMorePress={() => setIsAddToWalletOpen(true)}
onSpendPress={openSpendSheet}
canAddFunds={canAddFundsToCard(fundsAccess)}
canWithdraw={canWithdrawFromCard(fundsAccess)}
/>
Expand All @@ -262,6 +283,16 @@ const CardDetailsPane = () => {
isOpen={isOpen && shouldShowWelcomePopup}
onClose={() => setShouldShowWelcomePopup(false)}
/>
{/* Wirex only — a Rain card prefunds itself and has no module to enable. Mounted
on the issuer rather than on the action row's own visibility, so the reveal
gate above always has a sheet to open. */}
{provider === CardProvider.WIREX && (
<RegisterSpendAction
isOpen={spendSheetSource !== null}
onOpenChange={open => setSpendSheetSource(open ? 'spending_sheet' : null)}
source={spendSheetSource ?? 'spending_sheet'}
/>
)}
<AddToWalletModal
trigger={null}
isOpen={isAddToWalletOpen || (isOpen && walletGuide !== null)}
Expand Down
30 changes: 28 additions & 2 deletions components/Card/NewCardDetails/CardRevealSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
import { EASE_OUT_EXPO, HERO_ENTER, HeroEnter } from '@/components/Card/NewCardDetails/heroMotion';
import NewCardArt from '@/components/Card/NewCardDetails/NewCardArt';
import { useCardDetailsReveal } from '@/hooks/useCardDetailsReveal';
import { useCardSpendRegistration } from '@/hooks/useCardSpendRegistration';
import { CardHolderName, CardProvider } from '@/lib/types';
import { canRevealCardDetails } from '@/lib/utils/cardHelpers';
import { useCardPaneStore } from '@/store/useCardPaneStore';

/** How long a copied card number is allowed to sit on the clipboard. */
Expand All @@ -35,6 +37,11 @@ interface CardRevealSectionProps {
provider?: CardProvider | null;
/** ISO 3166-1 alpha-2 code of the card's issuing country, when known. */
issuingCountryCode?: string;
/**
* Opens the card-spending sheet, which is where a "Show details" tap goes on a Wirex
* card that cannot spend yet — see the gate in the body.
*/
onRequireSpendSetup: () => void;
}

/**
Expand All @@ -46,15 +53,24 @@ interface CardRevealSectionProps {
* If the reveal request fails the card stays on its front face and the reason is
* surfaced as a toast, so the toggle can be tried again. It must never flip onto
* stand-in digits: that reads as a working card the user might try to spend.
*
* On a Wirex card the same rule extends to a card that cannot spend at all — see
* `isRevealBlocked`.
*/
const CardRevealSection = ({
last4,
cardholderName,
provider,
issuingCountryCode,
onRequireSpendSetup,
}: CardRevealSectionProps) => {
const { cardDetails, isLoading, error, revealDetails, clearCardDetails } =
useCardDetailsReveal(provider);
const { isRegistered: canCardSpend, isLoading: isSpendStateLoading } = useCardSpendRegistration();
// A card whose Safe cannot be debited declines every payment, so its numbers are not
// worth revealing — the tap opens the sheet that fixes that instead. The rule itself,
// and why it fails closed on a read that has not landed, is in `canRevealCardDetails`.
const isRevealBlocked = !canRevealCardDetails({ provider, canCardSpend });
const isPaneOpen = useCardPaneStore(state => state.isOpen);
const [isRevealed, setIsRevealed] = useState(false);
const [hasRequested, setHasRequested] = useState(false);
Expand Down Expand Up @@ -129,12 +145,18 @@ const CardRevealSection = ({
clearCardDetails();
return;
}
// Never reached while the spend state is still loading — the toggle carries the
// spinner and is disabled until it is known.
if (isRevealBlocked) {
onRequireSpendSetup();
return;
}
setHasRequested(true);
void revealDetails().catch(() => {
// Swallowed: the hook surfaces the failure through `error`, which the effect
// above turns into a toast.
});
}, [isRevealed, revealDetails, clearCardDetails]);
}, [isRevealed, isRevealBlocked, onRequireSpendSetup, revealDetails, clearCardDetails]);

const copy = useCallback((label: string, value: string, autoClear = false) => {
void (async () => {
Expand Down Expand Up @@ -225,7 +247,11 @@ const CardRevealSection = ({
<HeroEnter spec={HERO_ENTER.showDetails}>
<CardDetailsPanel
isRevealed={isRevealed}
isLoading={isLoading}
// Spinning while the spend read is in flight, so the gate above is never
// decided on a state nobody has read yet. Only ever true on a Wirex card:
// the read is disabled for any other issuer, and a disabled query does not
// report loading.
isLoading={isLoading || isSpendStateLoading}
onToggle={handleToggle}
values={values}
onCopyName={() => copy('Name on card', values.nameOnCard)}
Expand Down
Loading
Loading