From a4b43a8e4b93a6cd2425c2e99a30a2913df8a187 Mon Sep 17 00:00:00 2001 From: mul53 Date: Mon, 28 Sep 2026 00:19:36 +0200 Subject: [PATCH] feat(deposit): offer Cash App under Fund your card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail only existed in the wallet flow. It now appears in all three card funding modals — Wirex, and Rain on mobile and desktop. The steps are the same screens; only the shell differs. OrchestraFlow renders them with the host's own navigator, the way BuyCryptoFlow already does for TransFi, and lib/orchestraFlow supplies the title and back target so each host asks the owning flow rather than keeping its own copy. Both embedded flows now route through one pair of helpers in each modal. Which balance gets funded is the entry point's to decide, not the screen's: useOrchestraCardEntry sets it to 'card' and the amount screen sends it, so the server resolves the card's own address instead of the Safe. The receive line follows suit — "in your card balance" rather than "in your wallet". A modal that forgot to set it would quietly fund the wallet from a screen titled Fund your card, which is why that lives in a shared hook rather than three copies. The row is passed only when the server says the deposit is available, so it stays hidden rather than disabled outside the US. Worth noting the neighbouring "Buy crypto" row is dead code today: it takes onBuyCryptoPress and none of the three modals pass it. Co-Authored-By: Claude Opus 5 --- .../Card/CardDirectDepositModalDesktop.tsx | 30 ++++++++-- .../Card/CardDirectDepositModalMobile.tsx | 30 ++++++++-- components/Card/CardFund/CardFundOptions.tsx | 18 +++++- components/Card/WirexCardFundModal.tsx | 30 ++++++++-- components/Orchestra/OrchestraAmount.tsx | 7 ++- components/Orchestra/OrchestraFlow.tsx | 41 ++++++++++++++ hooks/useOrchestra.ts | 11 +++- hooks/useOrchestraCardEntry.ts | 46 ++++++++++++++++ lib/api/orchestra.ts | 7 ++- lib/orchestraFlow.ts | 55 +++++++++++++++++++ store/useOrchestraStore.ts | 17 +++++- 11 files changed, 273 insertions(+), 19 deletions(-) create mode 100644 components/Orchestra/OrchestraFlow.tsx create mode 100644 hooks/useOrchestraCardEntry.ts create mode 100644 lib/orchestraFlow.ts diff --git a/components/Card/CardDirectDepositModalDesktop.tsx b/components/Card/CardDirectDepositModalDesktop.tsx index 405dac48c..0439177ec 100644 --- a/components/Card/CardDirectDepositModalDesktop.tsx +++ b/components/Card/CardDirectDepositModalDesktop.tsx @@ -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'; @@ -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'; @@ -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'; @@ -59,6 +62,16 @@ const MODAL_STATES: Record = { * 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 }; @@ -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( () => () => { @@ -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) { @@ -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'; @@ -369,7 +386,11 @@ export default function CardDirectDepositModal({ const content = (() => { if (buyCryptoModal) { - return ; + return isOrchestraModal(buyCryptoModal) ? ( + + ) : ( + + ); } if (step === 'options') { return ( @@ -379,6 +400,7 @@ export default function CardDirectDepositModal({ onExternalWalletPress={handleConnectWallet} onUsdPress={handleUsdPress} onLocalCurrencyPress={handleLocalCurrencyPress} + onCashAppPress={isCashAppAvailable ? openCashApp : undefined} isExternalWalletLoading={isWalletOpen} /> ); diff --git a/components/Card/CardDirectDepositModalMobile.tsx b/components/Card/CardDirectDepositModalMobile.tsx index 9ccd0a20f..3f321a491 100644 --- a/components/Card/CardDirectDepositModalMobile.tsx +++ b/components/Card/CardDirectDepositModalMobile.tsx @@ -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'; @@ -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'; @@ -51,6 +54,16 @@ const MODAL_STATES: Record = { * 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 }; @@ -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( () => () => { @@ -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) { @@ -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'; @@ -297,7 +314,11 @@ export default function CardDirectDepositModalMobile({ const content = (() => { if (buyCryptoModal) { - return ; + return isOrchestraModal(buyCryptoModal) ? ( + + ) : ( + + ); } if (step === 'options') { return ( @@ -307,6 +328,7 @@ export default function CardDirectDepositModalMobile({ onExternalWalletPress={handleExternalWallet} onUsdPress={handleUsdPress} onLocalCurrencyPress={handleLocalCurrencyPress} + onCashAppPress={isCashAppAvailable ? openCashApp : undefined} /> ); } diff --git a/components/Card/CardFund/CardFundOptions.tsx b/components/Card/CardFund/CardFundOptions.tsx index 007ed9d74..863b5fe74 100644 --- a/components/Card/CardFund/CardFundOptions.tsx +++ b/components/Card/CardFund/CardFundOptions.tsx @@ -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'; @@ -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 @@ -63,6 +69,7 @@ const CardFundOptions = ({ onUsdPress, onLocalCurrencyPress, onBuyCryptoPress, + onCashAppPress, isExternalWalletLoading, sections = RAIN_CARD_FUND_SECTIONS, moveFromSolidCopy = CARD_FUND_MOVE_COPY.rain, @@ -147,6 +154,15 @@ const CardFundOptions = ({ onPress={onBuyCryptoPress} /> ) : null} + {onCashAppPress ? ( + } + title="Cash App" + subtitle="Pay from your Cash App balance" + chips={['Instant']} + onPress={onCashAppPress} + /> + ) : null} ) : null} diff --git a/components/Card/WirexCardFundModal.tsx b/components/Card/WirexCardFundModal.tsx index a8b760b23..72b08c0fb 100644 --- a/components/Card/WirexCardFundModal.tsx +++ b/components/Card/WirexCardFundModal.tsx @@ -18,10 +18,12 @@ 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 { @@ -29,6 +31,7 @@ import { 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'; @@ -36,6 +39,16 @@ 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 = { @@ -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) => { @@ -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) { @@ -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; @@ -291,7 +308,11 @@ export default function WirexCardFundModal({ const content = (() => { if (buyCryptoModal) { - return ; + return isOrchestraModal(buyCryptoModal) ? ( + + ) : ( + + ); } if (step === 'options') { return ( @@ -299,6 +320,7 @@ export default function WirexCardFundModal({ onTokenPress={handleTokenPress} onLocalCurrencyPress={handleLocalCurrencyPress} onMoveFromSavingsPress={handleMoveFromWalletPress} + onCashAppPress={isCashAppAvailable ? openCashApp : undefined} sections={WIREX_CARD_FUND_SECTIONS} moveFromSolidCopy={CARD_FUND_MOVE_COPY.wirex} /> diff --git a/components/Orchestra/OrchestraAmount.tsx b/components/Orchestra/OrchestraAmount.tsx index 2b95df88a..f9251ec95 100644 --- a/components/Orchestra/OrchestraAmount.tsx +++ b/components/Orchestra/OrchestraAmount.tsx @@ -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); @@ -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), }); @@ -173,7 +175,8 @@ export const OrchestraAmount = () => { You'll receive {symbol} - {network ? ` on ${network}` : ''} in your wallet + {network ? ` on ${network}` : ''}{' '} + {destination === 'card' ? 'in your card balance' : 'in your wallet'} diff --git a/components/Orchestra/OrchestraFlow.tsx b/components/Orchestra/OrchestraFlow.tsx new file mode 100644 index 000000000..c4c769cc5 --- /dev/null +++ b/components/Orchestra/OrchestraFlow.tsx @@ -0,0 +1,41 @@ +import { OrchestraAmount } from '@/components/Orchestra/OrchestraAmount'; +import { OrchestraError } from '@/components/Orchestra/OrchestraError'; +import { OrchestraInvoice } from '@/components/Orchestra/OrchestraInvoice'; +import { + OrchestraNavigate, + OrchestraNavigationProvider, +} from '@/components/Orchestra/OrchestraNavigation'; +import { OrchestraOrderStatus } from '@/components/Orchestra/OrchestraOrderStatus'; +import { DEPOSIT_MODAL } from '@/constants/modals'; + +import type { DepositModal } from '@/lib/types'; + +/** + * The Cash App steps, rendered inside a host's own modal. + * + * The Add-funds modal renders these through useDepositOption instead; this is + * for the card funding modals, which run the same steps in a different shell + * and supply their own navigator. Every step calls useOrchestraNavigation + * rather than the deposit store, which is what makes that substitution work. + */ +export const OrchestraFlowContent = ({ + modal, + navigate, +}: { + modal: DepositModal; + navigate: OrchestraNavigate; +}) => ( + + {modal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE.name ? ( + + ) : modal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_STATUS.name ? ( + + ) : modal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_ERROR.name ? ( + + ) : ( + + )} + +); + +export default OrchestraFlowContent; diff --git a/hooks/useOrchestra.ts b/hooks/useOrchestra.ts index 83e97e131..394c42a40 100644 --- a/hooks/useOrchestra.ts +++ b/hooks/useOrchestra.ts @@ -56,8 +56,15 @@ export function useOrchestraConfig(countryCode?: string, enabled = true) { */ export function useCreateOrchestraOnramp() { return useMutation({ - mutationFn: ({ amountFiatUsd, countryCode }: { amountFiatUsd: string; countryCode?: string }) => - withRefreshToken(() => createOrchestraOnramp(amountFiatUsd, countryCode)), + mutationFn: ({ + amountFiatUsd, + countryCode, + destination, + }: { + amountFiatUsd: string; + countryCode?: string; + destination?: 'wallet' | 'card'; + }) => withRefreshToken(() => createOrchestraOnramp(amountFiatUsd, countryCode, destination)), retry: false, }); } diff --git a/hooks/useOrchestraCardEntry.ts b/hooks/useOrchestraCardEntry.ts new file mode 100644 index 000000000..ec83a0135 --- /dev/null +++ b/hooks/useOrchestraCardEntry.ts @@ -0,0 +1,46 @@ +import { useCallback } from 'react'; + +import { OrchestraNavigate } from '@/components/Orchestra/OrchestraNavigation'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; +import { useOrchestraConfig } from '@/hooks/useOrchestra'; +import { track } from '@/lib/analytics'; +import { useOrchestraStore } from '@/store/useOrchestraStore'; + +/** + * Opening the Cash App onramp from a card funding modal. + * + * Shared by the three of them so the availability gate and the destination are + * set the same way in each — a modal that forgot `setDestination('card')` + * would quietly fund the wallet from a screen titled "Fund your card". + * + * `isAvailable` is the server's verdict, the same one the wallet flow gates + * on: region or allowlist. Hosts pass `onCashAppPress` only when it is true, + * which is how the row stays hidden rather than disabled. + */ +export const useOrchestraCardEntry = (navigate: OrchestraNavigate) => { + const reset = useOrchestraStore(state => state.reset); + const setDestination = useOrchestraStore(state => state.setDestination); + + const { countryCode, isResolving } = useCashAppDepositAvailability(); + const { data: config } = useOrchestraConfig(countryCode, !isResolving); + + const openCashApp = useCallback(() => { + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { + deposit_method: 'buy_crypto', + provider: 'orchestra', + currency: 'USD', + destination: 'card', + }); + // A previous order's invoice and read token would otherwise still be in + // the store, and the status screen would track it instead of the new one. + reset(); + setDestination('card'); + navigate(DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT); + }, [navigate, reset, setDestination]); + + return { openCashApp, isAvailable: config?.isAvailable === true }; +}; + +export default useOrchestraCardEntry; diff --git a/lib/api/orchestra.ts b/lib/api/orchestra.ts index 07b31f61d..903f79600 100644 --- a/lib/api/orchestra.ts +++ b/lib/api/orchestra.ts @@ -68,12 +68,17 @@ export const getOrchestraConfig = async ( export const createOrchestraOnramp = async ( amountFiatUsd: string, countryCode?: string, + destination?: 'wallet' | 'card', ): Promise => { const response = await fetch(`${ORCHESTRA_BASE}/onramp`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...orchestraHeaders() }, credentials: 'include', - body: JSON.stringify({ amountFiatUsd, ...(countryCode ? { countryCode } : {}) }), + body: JSON.stringify({ + amountFiatUsd, + ...(countryCode ? { countryCode } : {}), + ...(destination ? { destination } : {}), + }), }); if (!response.ok) throw await toOrchestraError(response); diff --git a/lib/orchestraFlow.ts b/lib/orchestraFlow.ts new file mode 100644 index 000000000..248c2e423 --- /dev/null +++ b/lib/orchestraFlow.ts @@ -0,0 +1,55 @@ +import { DEPOSIT_MODAL } from '@/constants/modals'; + +import type { DepositModal } from '@/lib/types'; + +/** + * Host-side description of the Cash App steps, so a funding modal can render + * them in its own shell — title, back target, and which steps belong to the + * flow at all. + * + * Mirrors lib/buyCryptoFlow.ts. The two providers share no code, but a host + * embedding either needs the same three answers. + */ +export type OrchestraBackTarget = DepositModal | 'entry'; + +export const ORCHESTRA_MODAL_NAMES: readonly string[] = [ + DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT.name, + DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE.name, + DEPOSIT_MODAL.OPEN_ORCHESTRA_STATUS.name, + DEPOSIT_MODAL.OPEN_ORCHESTRA_ERROR.name, +]; + +export const isOrchestraModal = (modal: DepositModal | null | undefined): boolean => + !!modal && ORCHESTRA_MODAL_NAMES.includes(modal.name); + +export const getOrchestraTitle = (modal: DepositModal) => { + switch (modal.name) { + case DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT.name: + return 'Cash App'; + case DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE.name: + return 'Pay with Cash App'; + case DEPOSIT_MODAL.OPEN_ORCHESTRA_STATUS.name: + return 'Deposit status'; + // The error screen carries its own headline and icon. + default: + return undefined; + } +}; + +/** + * Where back goes from each step, or null where there is nowhere sensible. + * + * Null past the invoice is deliberate: by the status step an invoice may + * already be paid, and the error screen is where a failure sent the user, so + * returning to the step that raised it would only reproduce it. + */ +export const getOrchestraBackTarget = (modal: DepositModal): OrchestraBackTarget | null => { + switch (modal.name) { + case DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT.name: + return 'entry'; + case DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE.name: + return DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT; + default: + return null; + } +}; diff --git a/store/useOrchestraStore.ts b/store/useOrchestraStore.ts index 3e5b7552d..b1db3a9f1 100644 --- a/store/useOrchestraStore.ts +++ b/store/useOrchestraStore.ts @@ -15,15 +15,25 @@ import type { OrchestraOnrampOrder } from '@/lib/types/orchestra'; * exactly once by /onramp, and losing it on an unmount would leave a paid * invoice the app can no longer track. */ +/** Which balance the deposit funds. Set by whichever entry point opened it. */ +export type OrchestraDestination = 'wallet' | 'card'; + interface OrchestraState { /** USD the user typed, as a string — "50.00". */ amountUsd: string; + /** + * Wallet or card. Held here rather than inferred on the amount screen, + * because only the entry point knows which balance the user asked to fund + * and the screen itself is identical either way. + */ + destination: OrchestraDestination; order: OrchestraOnrampOrder | null; /** The failure the error step renders; survives the step transition. */ error: OrchestraError | null; /** The step the failure came from, so "Try again" returns there. */ errorOrigin: DepositModal | null; setAmountUsd: (amountUsd: string) => void; + setDestination: (destination: OrchestraDestination) => void; setOrder: (order: OrchestraOnrampOrder) => void; setError: (error: OrchestraError | null, origin?: DepositModal) => void; reset: () => void; @@ -31,11 +41,16 @@ interface OrchestraState { export const useOrchestraStore = create(set => ({ amountUsd: '', + destination: 'wallet', order: null, error: null, errorOrigin: null, setAmountUsd: amountUsd => set({ amountUsd }), + setDestination: destination => set({ destination }), setOrder: order => set({ order }), setError: (error, origin) => set({ error, errorOrigin: origin ?? null }), - reset: () => set({ amountUsd: '', order: null, error: null, errorOrigin: null }), + // `destination` deliberately resets too: a reset happens when an entry point + // starts a new deposit, and it sets the destination immediately after. + reset: () => + set({ amountUsd: '', destination: 'wallet', order: null, error: null, errorOrigin: null }), }));