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
30 changes: 26 additions & 4 deletions components/Card/CardDirectDepositModalDesktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import DepositNetwork from '@/components/DepositNetwork/DepositNetwork';
import AddFundsToWalletForm from '@/components/DepositOption/AddFundsToWalletForm';
import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog';
import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow';
import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
import { Text } from '@/components/ui/text';
import { BRIDGE_TOKENS } from '@/constants/bridge';
Expand All @@ -25,6 +26,7 @@ import { TRACKING_EVENTS } from '@/constants/tracking-events';
import { useBuyCryptoEntry } from '@/hooks/useBuyCryptoEntry';
import { useCardStatus } from '@/hooks/useCardStatus';
import { useOnrampAutomation } from '@/hooks/useOnrampAutomation';
import { useOrchestraCardEntry } from '@/hooks/useOrchestraCardEntry';
import { useVirtualAccountProvider } from '@/hooks/useVirtualAccountProvider';
import { track } from '@/lib/analytics';
import { createDirectDepositSession } from '@/lib/api';
Expand All @@ -33,6 +35,7 @@ import {
getBuyCryptoTitle,
getEmbeddedBuyCryptoTarget,
} from '@/lib/buyCryptoFlow';
import { getOrchestraBackTarget, getOrchestraTitle, isOrchestraModal } from '@/lib/orchestraFlow';
import { cleanupThirdwebStyles, client, thirdwebTheme, thirdwebWallets } from '@/lib/thirdweb';
import { CardProvider, DepositModal, RainApplicationStatus } from '@/lib/types';
import { withRefreshToken } from '@/lib/utils';
Expand All @@ -59,6 +62,16 @@ const MODAL_STATES: Record<Step, ModalState> = {
* this one unmounts leaves the closing sheet's view on top of the new one,
* swallowing its taps.
*/
/**
* Both embedded flows render in this shell, so the host asks whichever owns the
* step for its title and back target rather than keeping a second copy.
*/
const getEmbeddedBackTarget = (modal: DepositModal) =>
isOrchestraModal(modal) ? getOrchestraBackTarget(modal) : getBuyCryptoBackTarget(modal);

const getEmbeddedTitle = (modal: DepositModal) =>
isOrchestraModal(modal) ? getOrchestraTitle(modal) : getBuyCryptoTitle(modal);

const HANDOFF_DELAY_MS = 260;

const TITLE_ICON_STYLE = { width: 24, height: 24, borderRadius: 12 };
Expand Down Expand Up @@ -173,6 +186,10 @@ export default function CardDirectDepositModal({
[goToStep, handleOpenChange],
);
const { handleBuyCryptoPress } = useBuyCryptoEntry(navigateBuyCrypto);
// Cash App reuses the embedded-flow machinery: its steps are DepositModal
// values too, and navigateBuyCrypto already maps CLOSE and OPEN_OPTIONS onto
// this modal's own actions.
const { openCashApp, isAvailable: isCashAppAvailable } = useOrchestraCardEntry(navigateBuyCrypto);

useEffect(
() => () => {
Expand Down Expand Up @@ -328,7 +345,7 @@ export default function CardDirectDepositModal({

const handleBack = useCallback(() => {
if (buyCryptoModal) {
const target = getBuyCryptoBackTarget(buyCryptoModal);
const target = getEmbeddedBackTarget(buyCryptoModal);
if (target === 'entry') {
goToStep('options');
} else if (target) {
Expand All @@ -348,11 +365,11 @@ export default function CardDirectDepositModal({

const { current: step, previous: previousModal } = stepState;
const canGoBack = buyCryptoModal
? getBuyCryptoBackTarget(buyCryptoModal) !== null
? getEmbeddedBackTarget(buyCryptoModal) !== null
: step !== 'options';

const title = (() => {
if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal);
if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal);
if (step === 'networks') return selectedToken;
if (step === 'address') return `Deposit ${selectedToken}`;
return 'Fund your card';
Expand All @@ -369,7 +386,11 @@ export default function CardDirectDepositModal({

const content = (() => {
if (buyCryptoModal) {
return <BuyCryptoFlowContent modal={buyCryptoModal} navigate={navigateBuyCrypto} />;
return isOrchestraModal(buyCryptoModal) ? (
<OrchestraFlowContent modal={buyCryptoModal} navigate={navigateBuyCrypto} />
) : (
<BuyCryptoFlowContent modal={buyCryptoModal} navigate={navigateBuyCrypto} />
);
}
if (step === 'options') {
return (
Expand All @@ -379,6 +400,7 @@ export default function CardDirectDepositModal({
onExternalWalletPress={handleConnectWallet}
onUsdPress={handleUsdPress}
onLocalCurrencyPress={handleLocalCurrencyPress}
onCashAppPress={isCashAppAvailable ? openCashApp : undefined}
isExternalWalletLoading={isWalletOpen}
/>
);
Expand Down
30 changes: 26 additions & 4 deletions components/Card/CardDirectDepositModalMobile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import {
} from '@/components/Card/CardFund/constants';
import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress';
import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog';
import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow';
import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
import { CARD_DEPOSIT_MODAL, DEPOSIT_MODAL } from '@/constants/modals';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
import { useBuyCryptoEntry } from '@/hooks/useBuyCryptoEntry';
import { useCardStatus } from '@/hooks/useCardStatus';
import { useOnrampAutomation } from '@/hooks/useOnrampAutomation';
import { useOrchestraCardEntry } from '@/hooks/useOrchestraCardEntry';
import { useVirtualAccountProvider } from '@/hooks/useVirtualAccountProvider';
import { track } from '@/lib/analytics';
import { createDirectDepositSession } from '@/lib/api';
Expand All @@ -28,6 +30,7 @@ import {
getBuyCryptoTitle,
getEmbeddedBuyCryptoTarget,
} from '@/lib/buyCryptoFlow';
import { getOrchestraBackTarget, getOrchestraTitle, isOrchestraModal } from '@/lib/orchestraFlow';
import { CardProvider, DepositModal, RainApplicationStatus } from '@/lib/types';
import { withRefreshToken } from '@/lib/utils';
import { useCardDepositStore } from '@/store/useCardDepositStore';
Expand All @@ -51,6 +54,16 @@ const MODAL_STATES: Record<Step, ModalState> = {
* this one unmounts leaves the closing sheet's view on top of the new one,
* swallowing its taps.
*/
/**
* Both embedded flows render in this shell, so the host asks whichever owns the
* step for its title and back target rather than keeping a second copy.
*/
const getEmbeddedBackTarget = (modal: DepositModal) =>
isOrchestraModal(modal) ? getOrchestraBackTarget(modal) : getBuyCryptoBackTarget(modal);

const getEmbeddedTitle = (modal: DepositModal) =>
isOrchestraModal(modal) ? getOrchestraTitle(modal) : getBuyCryptoTitle(modal);

const HANDOFF_DELAY_MS = 260;

const TITLE_ICON_STYLE = { width: 24, height: 24, borderRadius: 12 };
Expand Down Expand Up @@ -151,6 +164,10 @@ export default function CardDirectDepositModalMobile({
[goToStep, handleOpenChange],
);
const { handleBuyCryptoPress } = useBuyCryptoEntry(navigateBuyCrypto);
// Cash App reuses the embedded-flow machinery: its steps are DepositModal
// values too, and navigateBuyCrypto already maps CLOSE and OPEN_OPTIONS onto
// this modal's own actions.
const { openCashApp, isAvailable: isCashAppAvailable } = useOrchestraCardEntry(navigateBuyCrypto);

useEffect(
() => () => {
Expand Down Expand Up @@ -260,7 +277,7 @@ export default function CardDirectDepositModalMobile({

const handleBack = useCallback(() => {
if (buyCryptoModal) {
const target = getBuyCryptoBackTarget(buyCryptoModal);
const target = getEmbeddedBackTarget(buyCryptoModal);
if (target === 'entry') {
goToStep('options');
} else if (target) {
Expand All @@ -276,11 +293,11 @@ export default function CardDirectDepositModalMobile({

const { current: step, previous: previousModal } = stepState;
const canGoBack = buyCryptoModal
? getBuyCryptoBackTarget(buyCryptoModal) !== null
? getEmbeddedBackTarget(buyCryptoModal) !== null
: step !== 'options';

const title = (() => {
if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal);
if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal);
if (step === 'networks') return selectedToken;
if (step === 'address') return `Deposit ${selectedToken}`;
return 'Fund your card';
Expand All @@ -297,7 +314,11 @@ export default function CardDirectDepositModalMobile({

const content = (() => {
if (buyCryptoModal) {
return <BuyCryptoFlowContent modal={buyCryptoModal} navigate={navigateBuyCrypto} />;
return isOrchestraModal(buyCryptoModal) ? (
<OrchestraFlowContent modal={buyCryptoModal} navigate={navigateBuyCrypto} />
) : (
<BuyCryptoFlowContent modal={buyCryptoModal} navigate={navigateBuyCrypto} />
);
}
if (step === 'options') {
return (
Expand All @@ -307,6 +328,7 @@ export default function CardDirectDepositModalMobile({
onExternalWalletPress={handleExternalWallet}
onUsdPress={handleUsdPress}
onLocalCurrencyPress={handleLocalCurrencyPress}
onCashAppPress={isCashAppAvailable ? openCashApp : undefined}
/>
);
}
Expand Down
18 changes: 17 additions & 1 deletion components/Card/CardFund/CardFundOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { View } from 'react-native';
import { Image } from 'expo-image';
import { CreditCard } from 'lucide-react-native';
import { CreditCard, Zap } from 'lucide-react-native';

import FundExternalWallet from '@/assets/images/fund-external-wallet';
import FundMoveSavings from '@/assets/images/fund-move-savings';
Expand Down Expand Up @@ -42,6 +42,12 @@ type CardFundOptionsProps = {
* opt in individually, as they do for the local-currency rows.
*/
onBuyCryptoPress?: () => void;
/**
* Opens the Cash App onramp, funding the card rather than the wallet. Omit
* to hide the row — the funding modals opt in individually, and the flow is
* US-only, so the host gates on availability before passing it.
*/
onCashAppPress?: () => void;
isExternalWalletLoading?: boolean;
/**
* Which groups this issuer offers. Defaults to the full Rain set, so the
Expand All @@ -63,6 +69,7 @@ const CardFundOptions = ({
onUsdPress,
onLocalCurrencyPress,
onBuyCryptoPress,
onCashAppPress,
isExternalWalletLoading,
sections = RAIN_CARD_FUND_SECTIONS,
moveFromSolidCopy = CARD_FUND_MOVE_COPY.rain,
Expand Down Expand Up @@ -147,6 +154,15 @@ const CardFundOptions = ({
onPress={onBuyCryptoPress}
/>
) : null}
{onCashAppPress ? (
<CardFundRow
icon={<Zap color="#94F27F" size={22} strokeWidth={1.5} />}
title="Cash App"
subtitle="Pay from your Cash App balance"
chips={['Instant']}
onPress={onCashAppPress}
/>
) : null}
</CardFundGroup>
) : null}

Expand Down
30 changes: 26 additions & 4 deletions components/Card/WirexCardFundModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,37 @@ import {
WirexMoveAmount,
WirexMoveHoldings,
} from '@/components/Card/CardFund/WirexMoveFromWallet';
import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow';
import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
import { useBuyCryptoEntry } from '@/hooks/useBuyCryptoEntry';
import { useCardProvider } from '@/hooks/useCardProvider';
import { useOrchestraCardEntry } from '@/hooks/useOrchestraCardEntry';
import { track } from '@/lib/analytics';
import { createDirectDepositSession } from '@/lib/api';
import {
getBuyCryptoBackTarget,
getBuyCryptoTitle,
getEmbeddedBuyCryptoTarget,
} from '@/lib/buyCryptoFlow';
import { getOrchestraBackTarget, getOrchestraTitle, isOrchestraModal } from '@/lib/orchestraFlow';
import { CardProvider, DepositModal } from '@/lib/types';
import { withRefreshToken } from '@/lib/utils';
import { MovableHolding } from '@/lib/utils/cardFundMove';
import { useTransfiStore } from '@/store/useTransfiStore';

type Step = 'options' | 'networks' | 'address' | 'moveHoldings' | 'moveAmount';

/**
* Both embedded flows render in this shell, so the host asks whichever owns the
* step for its title and back target rather than keeping a second copy.
*/
const getEmbeddedBackTarget = (modal: DepositModal) =>
isOrchestraModal(modal) ? getOrchestraBackTarget(modal) : getBuyCryptoBackTarget(modal);

const getEmbeddedTitle = (modal: DepositModal) =>
isOrchestraModal(modal) ? getOrchestraTitle(modal) : getBuyCryptoTitle(modal);

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

const MODAL_STATES: Record<Step, ModalState> = {
Expand Down Expand Up @@ -165,6 +178,10 @@ export default function WirexCardFundModal({
[goToStep, handleOpenChange],
);
const { handleBuyCryptoPress } = useBuyCryptoEntry(navigateBuyCrypto);
// Cash App reuses the embedded-flow machinery: its steps are DepositModal
// values too, and navigateBuyCrypto already maps CLOSE and OPEN_OPTIONS onto
// this modal's own actions.
const { openCashApp, isAvailable: isCashAppAvailable } = useOrchestraCardEntry(navigateBuyCrypto);

const handleTokenPress = useCallback(
(symbol: string) => {
Expand Down Expand Up @@ -247,7 +264,7 @@ export default function WirexCardFundModal({

const handleBack = useCallback(() => {
if (buyCryptoModal) {
const target = getBuyCryptoBackTarget(buyCryptoModal);
const target = getEmbeddedBackTarget(buyCryptoModal);
if (target === 'entry') {
goToStep('options');
} else if (target) {
Expand All @@ -268,11 +285,11 @@ export default function WirexCardFundModal({

const { current: step, previous: previousModal } = stepState;
const canGoBack = buyCryptoModal
? getBuyCryptoBackTarget(buyCryptoModal) !== null
? getEmbeddedBackTarget(buyCryptoModal) !== null
: step !== 'options';

const title = (() => {
if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal);
if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal);
if (step === 'networks') return selectedToken;
if (step === 'address') return `Deposit ${selectedToken}`;
if (step === 'moveHoldings') return CARD_FUND_MOVE_COPY.wirex.title;
Expand All @@ -291,14 +308,19 @@ export default function WirexCardFundModal({

const content = (() => {
if (buyCryptoModal) {
return <BuyCryptoFlowContent modal={buyCryptoModal} navigate={navigateBuyCrypto} />;
return isOrchestraModal(buyCryptoModal) ? (
<OrchestraFlowContent modal={buyCryptoModal} navigate={navigateBuyCrypto} />
) : (
<BuyCryptoFlowContent modal={buyCryptoModal} navigate={navigateBuyCrypto} />
);
}
if (step === 'options') {
return (
<CardFundOptions
onTokenPress={handleTokenPress}
onLocalCurrencyPress={handleLocalCurrencyPress}
onMoveFromSavingsPress={handleMoveFromWalletPress}
onCashAppPress={isCashAppAvailable ? openCashApp : undefined}
sections={WIREX_CARD_FUND_SECTIONS}
moveFromSolidCopy={CARD_FUND_MOVE_COPY.wirex}
/>
Expand Down
7 changes: 5 additions & 2 deletions components/Orchestra/OrchestraAmount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const OrchestraAmount = () => {
const setAmountUsd = useOrchestraStore(state => state.setAmountUsd);
const setOrder = useOrchestraStore(state => state.setOrder);
const setError = useOrchestraStore(state => state.setError);
const destination = useOrchestraStore(state => state.destination);

useEffect(() => {
track(TRACKING_EVENTS.ORCHESTRA_AMOUNT_VIEWED);
Expand Down Expand Up @@ -109,12 +110,13 @@ export const OrchestraAmount = () => {
// Two decimal places, because the box holds whatever is being typed and
// "10." or "10.999" should not reach the wire.
createOrder(
{ amountFiatUsd: amountNum.toFixed(2), countryCode },
{ amountFiatUsd: amountNum.toFixed(2), countryCode, destination },
{
onSuccess: order => {
track(TRACKING_EVENTS.ORCHESTRA_ORDER_CREATED, {
order_id: order.orderId,
amount_usd: amountNum,
destination,
amount_mode: order.amountMode,
has_cash_app_link: Boolean(order.paymentLinks?.cashApp),
});
Expand Down Expand Up @@ -173,7 +175,8 @@ export const OrchestraAmount = () => {
</View>
<Text className="text-sm font-medium leading-5 text-white/70">
You&apos;ll receive {symbol}
{network ? ` on ${network}` : ''} in your wallet
{network ? ` on ${network}` : ''}{' '}
{destination === 'card' ? 'in your card balance' : 'in your wallet'}
</Text>
</View>

Expand Down
Loading
Loading