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 }), }));