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
4 changes: 2 additions & 2 deletions components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ const APP_SCHEME = 'solid://';
* `mediaCapturePermissionGrantType` answers the WebView's own camera prompt,
* which is asked separately from the OS permission.
*/
export const OnramperWidget = ({ onOutcome }: OnramperWidgetProps) => {
const { data: session, isPending, isError, refetch } = useOnramperWidget();
export const OnramperWidget = ({ destination = 'wallet', onOutcome }: OnramperWidgetProps) => {
const { data: session, isPending, isError, refetch } = useOnramperWidget(destination);
const [isOpeningBrowser, setIsOpeningBrowser] = useState(false);

/**
Expand Down
4 changes: 2 additions & 2 deletions components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import {
* missing entry raises no error — the step just quietly does nothing, deep
* inside a provider's flow where we have no visibility at all.
*/
export const OnramperWidget = (_props: OnramperWidgetProps) => {
const { data: session, isPending, isError, refetch } = useOnramperWidget();
export const OnramperWidget = ({ destination = 'wallet' }: OnramperWidgetProps) => {
const { data: session, isPending, isError, refetch } = useOnramperWidget(destination);

if (isPending) return <OnramperWidgetLoading />;
if (isError || !session) {
Expand Down
8 changes: 8 additions & 0 deletions components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import { ActivityIndicator, View } from 'react-native';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';

import type { OnramperDestination } from '@/lib/api';

export interface OnramperWidgetProps {
/**
* What the purchase funds. The card funding screens pass `card`, which the
* backend resolves to the user's card deposit address; everything else funds
* the wallet.
*/
destination?: OnramperDestination;
/**
* Called when a provider redirects back to us, on the platforms that redirect
* at all. Advisory only — not every provider honours a redirect, so the
Expand Down
5 changes: 3 additions & 2 deletions components/BuyCrypto/Transfi/BuyCryptoFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ export const BuyCryptoFlowContent = ({
// Onramper's hosted widget. It lives here rather than in a switch of its
// own because this is the generic embed point: the card funding modals
// render whatever DepositModal they are handed, so one case reaches all
// three of them.
// three of them. Being opened from a card funding modal is also why it
// delivers to the card deposit address rather than the wallet.
case DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name:
return <OnramperWidget />;
return <OnramperWidget destination="card" />;
default:
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';

import { BuyCryptoFlowContent } from '@/components/BuyCrypto/Transfi/BuyCryptoFlow';
import { DEPOSIT_MODAL } from '@/constants/modals';

// eslint-disable-next-line @typescript-eslint/no-require-imports
const { act, create } = require('react-test-renderer');

jest.mock('@/components/BuyCrypto/OnramperWidget/OnramperWidget', () => ({
OnramperWidget: 'OnramperWidget',
}));
jest.mock('@/components/BuyCrypto/Transfi/BuyCryptoNavigation', () => ({
BuyCryptoNavigationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock('@/components/BuyCrypto/Transfi/TransfiAmount', () => ({ TransfiAmount: 'Screen' }));
jest.mock('@/components/BuyCrypto/Transfi/TransfiCurrencySelector', () => ({
TransfiCurrencySelector: 'Screen',
}));
jest.mock('@/components/BuyCrypto/Transfi/TransfiError', () => ({ TransfiError: 'Screen' }));
jest.mock('@/components/BuyCrypto/Transfi/TransfiKycConsent', () => ({
TransfiKycConsent: 'Screen',
}));
jest.mock('@/components/BuyCrypto/Transfi/TransfiKycPending', () => ({
TransfiKycPending: 'Screen',
}));
jest.mock('@/components/BuyCrypto/Transfi/TransfiOrderStatus', () => ({
TransfiOrderStatus: 'Screen',
}));
jest.mock('@/components/BuyCrypto/Transfi/TransfiPayment', () => ({ TransfiPayment: 'Screen' }));
jest.mock('@/components/BuyCrypto/Transfi/TransfiPaymentMethodSelector', () => ({
TransfiPaymentMethodSelector: 'Screen',
}));
jest.mock('@/components/BuyCrypto/Transfi/TransfiProfileForm', () => ({
TransfiProfileForm: 'Screen',
}));

beforeEach(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
});

// Only the card funding modals embed this content, so the widget it opens must
// fund the card — a wallet URL here would deliver the purchase to the Safe.
it('opens the Onramper widget delivering to the card', () => {
let root: any;
act(() => {
root = create(
<BuyCryptoFlowContent modal={DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET} navigate={jest.fn()} />,
);
});

expect(root.root.findByType('OnramperWidget').props.destination).toBe('card');
act(() => root.unmount());
});
40 changes: 34 additions & 6 deletions components/Card/CardDirectDepositModalDesktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@/components/Card/CardFund/constants';
import DepositNetwork from '@/components/DepositNetwork/DepositNetwork';
import AddFundsToWalletForm from '@/components/DepositOption/AddFundsToWalletForm';
import { UsdMethodList } from '@/components/DepositOption/DepositUsdOptions';
import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog';
import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow';
import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
Expand Down Expand Up @@ -44,12 +45,13 @@ import { useCardDepositStore } from '@/store/useCardDepositStore';
import { useDepositStore } from '@/store/useDepositStore';
import { useTransfiStore } from '@/store/useTransfiStore';

type Step = 'options' | 'networks' | 'address' | 'walletNetworks' | 'form';
type Step = 'options' | 'usdMethods' | 'networks' | 'address' | 'walletNetworks' | 'form';

const CLOSE_STATE: ModalState = { name: 'close', number: -1 };

const MODAL_STATES: Record<Step, ModalState> = {
options: { name: 'options', number: 0 },
usdMethods: { name: 'usd-methods', number: 1 },
networks: { name: 'networks', number: 1 },
walletNetworks: { name: 'wallet-networks', number: 1 },
address: { name: 'address', number: 2 },
Expand Down Expand Up @@ -198,9 +200,13 @@ export default function CardDirectDepositModal({
[],
);

// First-time setup stacks above this funding dialog so closing it returns to
// "Fund your card". Existing accounts still hand off to the global details flow.
const handleUsdPress = useCallback(() => {
// USD lists its methods — the bank rail and Apple Pay — as the wallet's cash
// flow does, rather than going straight to the bank rail.
const handleUsdPress = useCallback(() => goToStep('usdMethods'), [goToStep]);

// First-time setup stacks above this funding dialog so closing it returns here.
// Existing accounts still hand off to the global details flow.
const handleBankTransferPress = useCallback(() => {
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
deposit_method: 'bank_transfer',
provider: virtualAccountProvider,
Expand Down Expand Up @@ -248,8 +254,17 @@ export default function CardDirectDepositModal({
);

// Onramper's hosted widget, rendered inside this modal by the same embedded
// navigator the TransFi screens use — so back and the title come from
// navigator the TransFi screens use — so its title comes from
// lib/buyCryptoFlow, not from a step of our own.
const handleApplePayPress = useCallback(() => {
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
deposit_method: 'buy_crypto',
provider: 'onramper',
currency: 'USD',
});
navigateBuyCrypto(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET);
}, [navigateBuyCrypto]);

// "Deposit from an external wallet" — connect a crypto wallet, then send.
const handleConnectWallet = useCallback(async () => {
try {
Expand Down Expand Up @@ -347,7 +362,10 @@ export default function CardDirectDepositModal({
if (buyCryptoModal) {
const target = getEmbeddedBackTarget(buyCryptoModal);
if (target === 'entry') {
goToStep('options');
// The widget is opened from the USD methods; the TransFi and Cash App
// screens from the options themselves.
const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name;
goToStep(isWidget ? 'usdMethods' : 'options');
} else if (target) {
navigateBuyCrypto(target);
}
Expand All @@ -370,6 +388,7 @@ export default function CardDirectDepositModal({

const title = (() => {
if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal);
if (step === 'usdMethods') return 'Deposit US Dollars';
if (step === 'networks') return selectedToken;
if (step === 'address') return `Deposit ${selectedToken}`;
return 'Fund your card';
Expand Down Expand Up @@ -406,6 +425,15 @@ export default function CardDirectDepositModal({
);
}

if (step === 'usdMethods') {
return (
<UsdMethodList
onBankTransferPress={handleBankTransferPress}
onApplePayPress={handleApplePayPress}
/>
);
}

if (step === 'networks') {
return <CardFundNetworks symbol={selectedToken} onSelect={handleDirectNetworkSelect} />;
}
Expand Down
40 changes: 34 additions & 6 deletions components/Card/CardDirectDepositModalMobile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getCardFundTokenIcon,
} from '@/components/Card/CardFund/constants';
import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress';
import { UsdMethodList } from '@/components/DepositOption/DepositUsdOptions';
import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog';
import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow';
import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
Expand All @@ -37,12 +38,13 @@ import { useCardDepositStore } from '@/store/useCardDepositStore';
import { useDepositStore } from '@/store/useDepositStore';
import { useTransfiStore } from '@/store/useTransfiStore';

type Step = 'options' | 'networks' | 'address' | 'externalAddress';
type Step = 'options' | 'usdMethods' | 'networks' | 'address' | 'externalAddress';

const CLOSE_STATE: ModalState = { name: 'close', number: -1 };

const MODAL_STATES: Record<Step, ModalState> = {
options: { name: 'options', number: 0 },
usdMethods: { name: 'usd-methods', number: 1 },
networks: { name: 'networks', number: 1 },
externalAddress: { name: 'external-address', number: 1 },
address: { name: 'address', number: 2 },
Expand Down Expand Up @@ -176,9 +178,13 @@ export default function CardDirectDepositModalMobile({
[],
);

// First-time setup stacks above this funding dialog so closing it returns to
// "Fund your card". Existing accounts still hand off to the global details flow.
const handleUsdPress = useCallback(() => {
// USD lists its methods — the bank rail and Apple Pay — as the wallet's cash
// flow does, rather than going straight to the bank rail.
const handleUsdPress = useCallback(() => goToStep('usdMethods'), [goToStep]);

// First-time setup stacks above this funding dialog so closing it returns here.
// Existing accounts still hand off to the global details flow.
const handleBankTransferPress = useCallback(() => {
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
deposit_method: 'bank_transfer',
provider: virtualAccountProvider,
Expand Down Expand Up @@ -226,8 +232,17 @@ export default function CardDirectDepositModalMobile({
);

// Onramper's hosted widget, rendered inside this modal by the same embedded
// navigator the TransFi screens use — so back and the title come from
// navigator the TransFi screens use — so its title comes from
// lib/buyCryptoFlow, not from a step of our own.
const handleApplePayPress = useCallback(() => {
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
deposit_method: 'buy_crypto',
provider: 'onramper',
currency: 'USD',
});
navigateBuyCrypto(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET);
}, [navigateBuyCrypto]);

const handleTransferFromWallet = useCallback(() => {
handleOpenChange(false);
setDepositModal(CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM);
Expand Down Expand Up @@ -279,7 +294,10 @@ export default function CardDirectDepositModalMobile({
if (buyCryptoModal) {
const target = getEmbeddedBackTarget(buyCryptoModal);
if (target === 'entry') {
goToStep('options');
// The widget is opened from the USD methods; the TransFi and Cash App
// screens from the options themselves.
const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name;
goToStep(isWidget ? 'usdMethods' : 'options');
} else if (target) {
navigateBuyCrypto(target);
}
Expand All @@ -298,6 +316,7 @@ export default function CardDirectDepositModalMobile({

const title = (() => {
if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal);
if (step === 'usdMethods') return 'Deposit US Dollars';
if (step === 'networks') return selectedToken;
if (step === 'address') return `Deposit ${selectedToken}`;
return 'Fund your card';
Expand Down Expand Up @@ -333,6 +352,15 @@ export default function CardDirectDepositModalMobile({
);
}

if (step === 'usdMethods') {
return (
<UsdMethodList
onBankTransferPress={handleBankTransferPress}
onApplePayPress={handleApplePayPress}
/>
);
}

if (step === 'networks') {
return <CardFundNetworks symbol={selectedToken} onSelect={handleNetworkSelect} />;
}
Expand Down
4 changes: 2 additions & 2 deletions components/Card/CardFund/CardFundOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type CardFundOptionsProps = {
onTokenPress: (symbol: string) => void;
onMoveFromSavingsPress?: () => void;
onExternalWalletPress?: () => void;
/** USD (ACH / Wire) — opens the virtual-account flow. */
/** USD — opens its methods: the virtual account (ACH / Wire) or Apple Pay. */
onUsdPress?: () => void;
/**
* A local currency (BRL, BDT…) — opens the buy-crypto onramp for it. Omit to
Expand Down Expand Up @@ -109,7 +109,7 @@ const CardFundOptions = ({
<Image source={CARD_FUND_USD_ICON} style={TOKEN_ICON_STYLE} contentFit="cover" />
}
title="USD"
chips={['ACH', 'Wire']}
chips={['ACH', 'Wire', 'Apple Pay']}
onPress={onUsdPress}
/>
) : null}
Expand Down
Loading
Loading