diff --git a/app/(protected)/(tabs)/card/pending.tsx b/app/(protected)/(tabs)/card/pending.tsx index 136b5cd1..08196b59 100644 --- a/app/(protected)/(tabs)/card/pending.tsx +++ b/app/(protected)/(tabs)/card/pending.tsx @@ -1,21 +1,29 @@ import { useEffect } from 'react'; +import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; import { CardStatusPage } from '@/components/Card/CardStatusPage'; import { path } from '@/constants/path'; import { useCardStatus } from '@/hooks/useCardStatus'; +import { getAsset } from '@/lib/assets'; import { CardStatus, KycStatus, RainApplicationStatus } from '@/lib/types'; import { hasCard } from '@/lib/utils'; +import { useKycStore } from '@/store/useKycStore'; const POLL_INTERVAL_MS = 5000; export default function CardPending() { const router = useRouter(); const { data: cardStatusResponse } = useCardStatus({ refetchInterval: POLL_INTERVAL_MS }); + const kycFlow = useKycStore(state => state.kycFlow); useEffect(() => { if (!cardStatusResponse) return; + // VA-initiated KYC: keep the user on the pending submission view. They + // re-enter the VA flow via the Deposit modal when KYC + Rain are ready. + if (kycFlow === 'va') return; + // User already has a card (e.g. status synced after this tab was open). if (hasCard(cardStatusResponse) && cardStatusResponse.status !== CardStatus.PENDING) { router.replace(path.CARD_DETAILS); @@ -45,14 +53,23 @@ export default function CardPending() { if (kycStatus && kycStatus !== KycStatus.NOT_STARTED) { router.replace(`${String(path.CARD_ACTIVATE)}?kycStatus=${kycStatus}` as any); } - }, [cardStatusResponse, router]); + }, [cardStatusResponse, kycFlow, router]); return ( + } /> ); } diff --git a/assets/images/identity-review.png b/assets/images/identity-review.png new file mode 100644 index 00000000..0c961f01 Binary files /dev/null and b/assets/images/identity-review.png differ diff --git a/components/Card/ActivateCard/UnderReviewState.tsx b/components/Card/ActivateCard/UnderReviewState.tsx index 3245a3a1..8bcd1f90 100644 --- a/components/Card/ActivateCard/UnderReviewState.tsx +++ b/components/Card/ActivateCard/UnderReviewState.tsx @@ -17,9 +17,10 @@ export function UnderReviewState() { /> - Your card is on its way! + Thank you for your submission! - Thanks for your submission. Your{'\n'}identity is now being verified. + Your identity is now being verified. You{'\n'}will be notified by mail once you get{'\n'} + approved diff --git a/components/Card/CardStatusPage.tsx b/components/Card/CardStatusPage.tsx index e85182b5..e1a303a6 100644 --- a/components/Card/CardStatusPage.tsx +++ b/components/Card/CardStatusPage.tsx @@ -11,16 +11,24 @@ interface CardStatusPageProps { title: string; description?: string; children?: ReactNode; + header?: string; + image?: ReactNode; } -export function CardStatusPage({ title, description, children }: CardStatusPageProps) { +export function CardStatusPage({ + title, + description, + children, + header, + image, +}: CardStatusPageProps) { return ( - Solid card + {header ? header : 'Solid card'} @@ -28,12 +36,16 @@ export function CardStatusPage({ title, description, children }: CardStatusPageP - Solid Card + {image ? ( + image + ) : ( + Solid Card + )} {title} diff --git a/components/CardWaitlist/SolidCardSummary.tsx b/components/CardWaitlist/SolidCardSummary.tsx index 1ace8b6d..9daabede 100644 --- a/components/CardWaitlist/SolidCardSummary.tsx +++ b/components/CardWaitlist/SolidCardSummary.tsx @@ -8,21 +8,19 @@ type FeatureItemProps = { icon: React.ReactNode; label: string; classNames?: { - container?: string - text?: string + container?: string; + text?: string; }; }; const FeatureItem = ({ icon, label, classNames }: FeatureItemProps) => ( - + {icon} - {label} + {label} ); -const CashbackBadge = () => ( - 3% -); +const CashbackBadge = () => 3%; type SolidCardSummaryProps = { topUpLabel?: string; @@ -38,12 +36,7 @@ const SolidCardSummary = ({ return ( - + Solid card @@ -54,9 +47,21 @@ const SolidCardSummary = ({ } label="Virtual card" /> } label="3% Cashback" /> - {compact && } label={topUpLabel} classNames={{container:"items-start"}} />} + {compact && ( + } + label={topUpLabel} + classNames={{ container: 'items-start' }} + /> + )} - {!compact && } label={topUpLabel} classNames={{container:"items-start"}} />} + {!compact && ( + } + label={topUpLabel} + classNames={{ container: 'items-start' }} + /> + )} ); }; diff --git a/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx b/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx index e87bc6e2..255a0024 100644 --- a/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx +++ b/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx @@ -1,65 +1,16 @@ -import { useCallback, useMemo } from 'react'; import { View } from 'react-native'; -import { Image } from 'expo-image'; import DepositOption from '@/components/DepositOption/DepositOption'; -import { DEPOSIT_MODAL } from '@/constants/modals'; -import { getAsset } from '@/lib/assets'; -import { DepositMethod } from '@/lib/types'; +import useDepositBuyCryptoOptions from '@/hooks/useDepositBuyCryptoOptions'; import { getVaultDepositConfig } from '@/lib/vaults'; -import { useDepositStore } from '@/store/useDepositStore'; const DepositBuyCryptoOptions = () => { - const setModal = useDepositStore(state => state.setModal); + const { buyCryptoOptions } = useDepositBuyCryptoOptions(); const depositConfig = getVaultDepositConfig(); - // const handleBankDepositPress = useCallback(() => { - // setModal(DEPOSIT_MODAL.OPEN_BANK_TRANSFER_AMOUNT); - // }, [setModal]); - - // const handleCreditCardPress = useCallback(() => { - // setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO); - // }, [setModal]); - - const buyCryptoOptions = useMemo( - () => [ - // { - // text: 'Debit/Credit Card', - // subtitle: 'Google Pay, card or bank account', - // icon: ( - // - // ), - // onPress: handleCreditCardPress, - // method: 'credit_card' as DepositMethod, - // }, - // { - // text: 'Bank Deposit', - // subtitle: 'Make a transfer from your bank.', - // icon: ( - // - // ), - // onPress: handleBankDepositPress, - // isComingSoon: false, - // method: 'bank_transfer' as DepositMethod, - // }, - ], - [ - // handleCreditCardPress, - // handleBankDepositPress - ], - ); - return ( - {/* {buyCryptoOptions + {buyCryptoOptions .filter(option => !option.method || depositConfig.methods.includes(option.method)) .map(option => ( { subtitle={option.subtitle} icon={option.icon} onPress={option.onPress} - // isComingSoon={option.isComingSoon} + chipText={option.chipText} /> - ))} */} + ))} ); }; diff --git a/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyModal.tsx b/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyModal.tsx new file mode 100644 index 00000000..504662e8 --- /dev/null +++ b/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyModal.tsx @@ -0,0 +1,69 @@ +import { useCallback } from 'react'; +import { View } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Building2 } from 'lucide-react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { path } from '@/constants/path'; +import { useCardStatus } from '@/hooks/useCardStatus'; +import { RainApplicationStatus } from '@/lib/types'; +import { useDepositStore } from '@/store/useDepositStore'; +import { useKycStore } from '@/store/useKycStore'; + +const BENEFITS = [ + 'A persistent virtual bank account in your name for ACH and Wire deposits.', + 'Incoming USD is auto-converted to USDC and deposited as soUSD.', + 'No fees from Solid — settlement typically in 1–3 business days.', +]; + +export const VirtualAccountApplyModal = () => { + const router = useRouter(); + const setModal = useDepositStore(state => state.setModal); + const setKycFlow = useKycStore(state => state.setKycFlow); + const { data: cardStatus } = useCardStatus(); + const isRainApproved = + cardStatus?.rainApplicationStatus === RainApplicationStatus.APPROVED; + + const handleApply = useCallback(() => { + if (isRainApproved) { + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_TOS); + return; + } + setKycFlow('va'); + setModal(DEPOSIT_MODAL.CLOSE); + router.push(path.KYC); + }, [isRainApproved, router, setKycFlow, setModal]); + + return ( + + + + + + Virtual Bank Account + + Get a US bank account in your name so you can deposit USD straight into soUSD. + + + + + {BENEFITS.map(item => ( + + + {item} + + ))} + + + + + ); +}; diff --git a/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal.tsx b/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal.tsx new file mode 100644 index 00000000..c0c425b9 --- /dev/null +++ b/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal.tsx @@ -0,0 +1,95 @@ +import { ActivityIndicator, View } from 'react-native'; + +import CopyToClipboard from '@/components/CopyToClipboard'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { useOnrampAutomation } from '@/hooks/useOnrampAutomation'; +import { useDepositStore } from '@/store/useDepositStore'; + +const Row = ({ + label, + value, + withDivider = false, +}: { + label: string; + value: string; + withDivider?: boolean; +}) => ( + + + + {label} + + + + {value} + + {value ? : null} + + + {withDivider && } + +); + +export const VirtualAccountDetailsModal = () => { + const setModal = useDepositStore(state => state.setModal); + const { data: automation, isLoading } = useOnrampAutomation(); + + if (isLoading) { + return ( + + + + ); + } + + if (!automation) { + return ( + + Could not load your bank details. + + + ); + } + + const { depositAddress } = automation; + + return ( + + + Deposit USD + + Send a transfer from your bank — funds arrive as soUSD in your Solid balance. + + + + + + + + + + + + + + + + ); +}; diff --git a/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal.tsx b/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal.tsx new file mode 100644 index 00000000..15e4b82d --- /dev/null +++ b/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal.tsx @@ -0,0 +1,143 @@ +import { ReactNode, useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Linking, Pressable, View } from 'react-native'; +import { BadgeCheck, Check } from 'lucide-react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { Underline } from '@/components/ui/underline'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { useCardStatus } from '@/hooks/useCardStatus'; +import { useCreateOnrampAutomation, useOnrampAutomation } from '@/hooks/useOnrampAutomation'; +import { RainApplicationStatus } from '@/lib/types'; +import { useDepositStore } from '@/store/useDepositStore'; + +const underlineProps = { + textClassName: 'text-sm font-bold text-white' as const, + borderColor: 'rgba(255, 255, 255, 1)' as const, +}; + +const TOS_POINTS: { key: string; content: ReactNode }[] = [ + { + key: 'issuance', + content: + 'A persistent virtual bank account will be issued in your name for ACH and Wire deposits.', + }, + { + key: 'conversion', + content: + 'Incoming USD is converted to USDC by Rain and automatically deposited into the soUSD vault on your behalf.', + }, +]; + +export const VirtualAccountTosModal = () => { + const setModal = useDepositStore(state => state.setModal); + const { data: existingAutomation } = useOnrampAutomation(); + const { data: cardStatus } = useCardStatus(); + const createMutation = useCreateOnrampAutomation(); + const [agreed, setAgreed] = useState(false); + const isRainApproved = cardStatus?.rainApplicationStatus === RainApplicationStatus.APPROVED; + + // Defensive: if an automation already exists, skip ToS straight to details. + useEffect(() => { + if (existingAutomation) { + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS); + } + }, [existingAutomation, setModal]); + + const handleAccept = useCallback(() => { + createMutation.mutate('ach', { + onSuccess: () => { + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS); + }, + }); + }, [createMutation, setModal]); + + return ( + + {isRainApproved && ( + + + + You’re approved + + Accept the terms below to issue your virtual bank account. + + + + )} + + + Before you continue + + Review the terms of the Rain virtual bank account. + + + + + {TOS_POINTS.map(point => ( + + + {point.content} + + ))} + + + setAgreed(prev => !prev)} + accessibilityRole="checkbox" + accessibilityState={{ checked: agreed }} + > + + {agreed && } + + + I accept the{' '} + + Linking.openURL( + 'https://support.solid.xyz/en/articles/15324239-solid-virtual-account-user-terms-of-service', + ) + } + > + Virtual Account User Terms of Service + + . + + + + + Solid is a financial technology company, not a bank. Banking services are provided by SSB, + Member FDIC. Funds deposited at SSB are eligible for FDIC insurance up to $250,000 per + depositor, per insured bank, subject to applicable limitations and FDIC rules. + + + {createMutation.isError && ( + + Something went wrong creating your bank account. Please try again. + + )} + + + + ); +}; diff --git a/components/kyc/useDiditSession.ts b/components/kyc/useDiditSession.ts index c4da1e6c..fb3c4dc6 100644 --- a/components/kyc/useDiditSession.ts +++ b/components/kyc/useDiditSession.ts @@ -10,6 +10,7 @@ import { track } from '@/lib/analytics'; import { createDiditSession, getCardStatus, getDiditVerificationStatus } from '@/lib/api'; import { KycStatus, RainApplicationStatus } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; +import { useKycStore } from '@/store/useKycStore'; export type SessionState = | { phase: 'loading' } @@ -23,6 +24,7 @@ const POLL_INTERVAL_MS = 5000; export function useDiditSession() { const router = useRouter(); const queryClient = useQueryClient(); + const kycFlow = useKycStore(state => state.kycFlow); const [session, setSession] = useState({ phase: 'loading' }); const sdkInitializedRef = useRef(false); @@ -69,6 +71,14 @@ export function useDiditSession() { setSession({ phase: 'completed' }); queryClient.invalidateQueries({ queryKey: [CARD_STATUS_QUERY_KEY] }); + // VA flow: KYC is just a gate for opening the virtual account. Always + // surface the pending submission page after KYC — the user re-enters + // the VA flow via Deposit when their KYC + Rain status is approved. + if (kycFlow === 'va') { + router.replace(path.CARD_PENDING as any); + return; + } + if (kycStatus === KycStatus.APPROVED) { // Didit KYC approved: route by Rain status. Approved -> ready. // Manual review (Rain pending/manualReview, which maps to backend @@ -95,7 +105,7 @@ export function useDiditSession() { router.replace(`${String(path.CARD_ACTIVATE)}?kycStatus=${kycStatus}` as any); } }, - [queryClient, router], + [kycFlow, queryClient, router], ); const onVerificationComplete = useCallback(() => { diff --git a/constants/modals.ts b/constants/modals.ts index 8bfaded0..4a56832c 100644 --- a/constants/modals.ts +++ b/constants/modals.ts @@ -75,6 +75,18 @@ export const DEPOSIT_MODAL = { name: 'open_token_selector', number: 17, }, + OPEN_VIRTUAL_ACCOUNT_DETAILS: { + name: 'open_virtual_account_details', + number: 18, + }, + OPEN_VIRTUAL_ACCOUNT_TOS: { + name: 'open_virtual_account_tos', + number: 19, + }, + OPEN_VIRTUAL_ACCOUNT_APPLY: { + name: 'open_virtual_account_apply', + number: 20, + }, }; export const SEND_MODAL = { diff --git a/hooks/useCardSteps/useCardSteps.ts b/hooks/useCardSteps/useCardSteps.ts index 0fd2b247..bbe44fba 100644 --- a/hooks/useCardSteps/useCardSteps.ts +++ b/hooks/useCardSteps/useCardSteps.ts @@ -40,14 +40,16 @@ export function useCardSteps( cardStatusResponse?: CardStatusResponse | null, ) { const router = useRouter(); - const { kycLinkId, processingUntil, setProcessingUntil, clearProcessingUntil } = useKycStore( - useShallow(state => ({ - kycLinkId: state.kycLinkId, - processingUntil: state.processingUntil, - setProcessingUntil: state.setProcessingUntil, - clearProcessingUntil: state.clearProcessingUntil, - })), - ); + const { kycLinkId, processingUntil, setProcessingUntil, clearProcessingUntil, setKycFlow } = + useKycStore( + useShallow(state => ({ + kycLinkId: state.kycLinkId, + processingUntil: state.processingUntil, + setProcessingUntil: state.setProcessingUntil, + clearProcessingUntil: state.clearProcessingUntil, + setKycFlow: state.setKycFlow, + })), + ); // Consider Rain when API returns rainApplicationStatus (provider may be omitted) const cardIssuer = cardStatusResponse?.rainApplicationStatus != null @@ -118,10 +120,13 @@ export function useCardSteps( // Default to Rain KYC; only Bridge goes through Bridge flow if (cardIssuer !== CardProvider.BRIDGE) { + setKycFlow('card'); router.push(path.KYC as any); return; } + setKycFlow('card'); + // Check country access (Bridge flow) const isBlocked = await checkAndBlockForCountryAccess(countryStore, kycLinkId); if (isBlocked) return; @@ -189,6 +194,7 @@ export function useCardSteps( countryStore, cardsEndorsement?.status, cardIssuer, + setKycFlow, ]); // Rain: KYC step button handler (redirect, contact support, or proceed to KYC) diff --git a/hooks/useDepositBuyCryptoOptions.tsx b/hooks/useDepositBuyCryptoOptions.tsx index acfd7f29..50efc762 100644 --- a/hooks/useDepositBuyCryptoOptions.tsx +++ b/hooks/useDepositBuyCryptoOptions.tsx @@ -4,73 +4,58 @@ import { Image } from 'expo-image'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useCardStatus } from '@/hooks/useCardStatus'; +import { useOnrampAutomation } from '@/hooks/useOnrampAutomation'; import { track } from '@/lib/analytics'; import { getAsset } from '@/lib/assets'; -import { DepositMethod } from '@/lib/types'; +import { DepositMethod, RainApplicationStatus } from '@/lib/types'; import { useDepositStore } from '@/store/useDepositStore'; -import { useDimension } from './useDimension'; - const useDepositBuyCryptoOptions = () => { const setModal = useDepositStore(state => state.setModal); - const { isScreenMedium } = useDimension(); + const { data: cardStatus } = useCardStatus(); + const isRainApproved = cardStatus?.rainApplicationStatus === RainApplicationStatus.APPROVED; + const { data: existingAutomation } = useOnrampAutomation(isRainApproved); + + const handleBankDepositPress = useCallback(() => { + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { + deposit_method: 'bank_transfer', + }); - // const handleBankDepositPress = useCallback(() => { - // track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { - // deposit_method: 'bank_transfer', - // }); - // setModal(DEPOSIT_MODAL.OPEN_BANK_TRANSFER_AMOUNT); - // }, [setModal]); + if (existingAutomation) { + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS); + return; + } - // const handleCreditCardPress = useCallback(() => { - // track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { - // deposit_method: 'credit_card', - // }); - // setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO); - // }, [setModal]); + // No automation yet — show the Apply intro. The intro CTA decides whether + // to send the user through KYC or straight to the ToS modal based on their + // current Rain approval status. + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_APPLY); + }, [existingAutomation, setModal]); const buyCryptoOptions = useMemo( () => [ - // { - // text: 'Debit/Credit Card', - // subtitle: isScreenMedium - // ? 'Apple pay, Google Pay, or your\ncredit card' - // : 'Apple pay, Google Pay, or your credit card', - // icon: ( - // - // ), - // onPress: handleCreditCardPress, - // method: 'credit_card' as DepositMethod, - // }, - // { - // text: 'Bank Deposit', - // subtitle: 'Make a transfer from your bank', - // chipText: 'Cheapest', - // icon: ( - // - // ), - // onPress: handleBankDepositPress, - // method: 'bank_transfer' as DepositMethod, - // }, - ], - [ - // handleCreditCardPress, - // // handleBankDepositPress, - isScreenMedium, + { + text: 'Bank Deposit', + subtitle: 'Wire or ACH from your bank.', + chipText: 'Cheapest', + icon: ( + + ), + onPress: handleBankDepositPress, + method: 'bank_transfer' as DepositMethod, + }, ], + [handleBankDepositPress], ); const filteredOptions = Platform.OS === 'ios' - ? buyCryptoOptions.filter((option: any) => option.method !== 'credit_card') + ? buyCryptoOptions.filter(option => option.method !== 'credit_card') : buyCryptoOptions; return { buyCryptoOptions: filteredOptions }; diff --git a/hooks/useDepositOption.tsx b/hooks/useDepositOption.tsx index 6038d96d..e1b6f097 100644 --- a/hooks/useDepositOption.tsx +++ b/hooks/useDepositOption.tsx @@ -19,6 +19,9 @@ import DepositDirectlyTokens from '@/components/DepositOption/DepositDirectlyTok import DepositExternalWalletOptions from '@/components/DepositOption/DepositExternalWalletOptions'; import DepositOptions from '@/components/DepositOption/DepositOptions'; import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress'; +import { VirtualAccountApplyModal } from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyModal'; +import { VirtualAccountDetailsModal } from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal'; +import { VirtualAccountTosModal } from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal'; import { DepositTokenSelector, DepositToVaultForm } from '@/components/DepositToVault'; import SavingsDepositTokenSelector from '@/components/DepositToVault/SavingsDepositTokenSelector'; import TransactionStatus from '@/components/TransactionStatus'; @@ -128,6 +131,10 @@ const useDepositOption = ({ const isDepositDirectlyTokens = currentModal.name === DEPOSIT_MODAL.OPEN_DEPOSIT_DIRECTLY_TOKENS.name; const isTokenSelector = currentModal.name === DEPOSIT_MODAL.OPEN_TOKEN_SELECTOR.name; + const isVirtualAccountDetails = + currentModal.name === DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS.name; + const isVirtualAccountTos = currentModal.name === DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_TOS.name; + const isVirtualAccountApply = currentModal.name === DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_APPLY.name; const isClose = currentModal.name === DEPOSIT_MODAL.CLOSE.name; const shouldAnimate = previousModal.name !== DEPOSIT_MODAL.CLOSE.name; const isForward = currentModal.number > previousModal.number; @@ -252,6 +259,18 @@ const useDepositOption = ({ return ; } + if (isVirtualAccountApply) { + return ; + } + + if (isVirtualAccountTos) { + return ; + } + + if (isVirtualAccountDetails) { + return ; + } + if (isTokenSelector) { if (depositFromSolid) { return ; @@ -280,6 +299,8 @@ const useDepositOption = ({ if (isDepositDirectlyAddress) return 'deposit-directly-address'; if (isDepositDirectlyTokens) return 'deposit-directly-tokens'; if (isTokenSelector) return 'token-selector'; + if (isVirtualAccountDetails) return 'virtual-account-details'; + if (isVirtualAccountTos) return 'virtual-account-tos'; return 'deposit-options'; }; @@ -297,6 +318,8 @@ const useDepositOption = ({ if (isDepositDirectlyTokens) return 'Choose token'; if (isTokenSelector && depositFromSolid) return 'Deposit'; if (isTokenSelector) return 'Select a token'; + if (isVirtualAccountDetails) return 'Bank Deposit'; + if (isVirtualAccountTos) return 'Bank Deposit'; if ((isNetworks || isFormAndAddress) && depositFromSolid) return 'Deposit'; if (isFormAndAddress && !depositFromSolid) return 'Add funds'; return 'Add funds'; diff --git a/hooks/useOnrampAutomation.ts b/hooks/useOnrampAutomation.ts new file mode 100644 index 00000000..864a4ff5 --- /dev/null +++ b/hooks/useOnrampAutomation.ts @@ -0,0 +1,30 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { createOnrampAutomation, getOnrampAutomation } from '@/lib/api'; +import { OnrampAutomationRail } from '@/lib/types'; +import { withRefreshToken } from '@/lib/utils'; + +const ONRAMP_AUTOMATION_KEY = 'onrampAutomation'; + +export function useOnrampAutomation(enabled = true) { + return useQuery({ + queryKey: [ONRAMP_AUTOMATION_KEY], + queryFn: () => withRefreshToken(() => getOnrampAutomation()), + enabled, + retry: 1, + }); +} + +export function useCreateOnrampAutomation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (rail: OnrampAutomationRail = 'ach') => { + const data = await withRefreshToken(() => createOnrampAutomation(rail)); + if (!data) throw new Error('Failed to create onramp automation'); + return data; + }, + onSuccess: data => { + queryClient.setQueryData([ONRAMP_AUTOMATION_KEY], data); + }, + }); +} diff --git a/lib/api.ts b/lib/api.ts index b78bba57..3307225d 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -71,6 +71,8 @@ import { LifiQuoteResponse, LifiStatusResponse, MppCredentialsResponse, + OnrampAutomationRail, + OnrampAutomationResponseDto, Points, PromotionsBannerResponse, ProvisioningSessionRequest, @@ -877,6 +879,53 @@ export const getCardContracts = async (): Promise => return response.json(); }; +/** + * Rain onramp automation: persistent virtual bank account (ACH + Wire). + * Returns 404 when the user has not yet created an automation. + */ +export const getOnrampAutomation = async (): Promise => { + const jwt = getJWTToken(); + + const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/onramp-automations`, { + credentials: 'include', + headers: { + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + }); + + if (response.status === 404) return null; + if (!response.ok) throw response; + + return response.json(); +}; + +/** + * Creates an onramp automation for the current user. Idempotent — the backend + * returns the existing automation if one is already active. Throws a Response + * with status 412 if Rain KYC is incomplete. + */ +export const createOnrampAutomation = async ( + rail: OnrampAutomationRail = 'ach', +): Promise => { + const jwt = getJWTToken(); + + const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/onramp-automations`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + body: JSON.stringify({ rail }), + }); + + if (!response.ok) throw response; + + return response.json(); +}; + /** Rain MPP: GET wallet eligibility for push provisioning. Throw Response on non-OK. */ export const getWalletEligibility = async (): Promise => { const jwt = getJWTToken(); diff --git a/lib/assets.ts b/lib/assets.ts index 0461a6b6..bce7b3b3 100644 --- a/lib/assets.ts +++ b/lib/assets.ts @@ -309,6 +309,10 @@ export const ASSETS = { 'images/home-qr.tsx': { module: require('@/assets/images/home-qr.tsx'), hash: '0c89afeb' }, 'images/home-send.tsx': { module: require('@/assets/images/home-send.tsx'), hash: '7a61497d' }, 'images/home-swap.tsx': { module: require('@/assets/images/home-swap.tsx'), hash: '5ea4a65b' }, + 'images/identity-review.png': { + module: require('@/assets/images/identity-review.png'), + hash: '6fe74800', + }, 'images/info-error.tsx': { module: require('@/assets/images/info-error.tsx'), hash: '75c1c092' }, 'images/invite-yellow.png': { module: require('@/assets/images/invite-yellow.png'), diff --git a/lib/types.ts b/lib/types.ts index e1bdef81..6cd996f5 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -637,6 +637,40 @@ export interface RainContractResponseDto { onramp?: RainContractOnrampDto; } +export type OnrampAutomationRail = 'ach' | 'wire'; + +export interface OnrampAutomationDepositAddressDto { + type: 'fiat'; + beneficiaryName: string; + beneficiaryAddress: string; + beneficiaryBankName: string; + beneficiaryBankAddress: string; + accountNumber: string; + routingNumber: string; +} + +export interface OnrampAutomationSourceDto { + currency: 'usd'; + rail: OnrampAutomationRail; +} + +export interface OnrampAutomationDestinationDto { + currency: string; + rail: string; + address: { type: 'onchain'; address: string }; +} + +export interface OnrampAutomationResponseDto { + id: string; + rainAutomationId: string; + status: 'active' | 'deleted' | 'failed'; + source: OnrampAutomationSourceDto; + destination: OnrampAutomationDestinationDto; + depositAddress: OnrampAutomationDepositAddressDto; + createdAt: string; + updatedAt: string; +} + export enum LayerZeroTransactionStatus { INFLIGHT = 'INFLIGHT', CONFIRMING = 'CONFIRMING', diff --git a/store/useKycStore.ts b/store/useKycStore.ts index b6a67571..acf0e5b7 100644 --- a/store/useKycStore.ts +++ b/store/useKycStore.ts @@ -3,6 +3,8 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import mmkvStorage from '@/lib/mmvkStorage'; +export type KycFlow = 'card' | 'va'; + interface KycState { kycLinkId: string | null; setKycLinkId: (kycLinkId: string) => void; @@ -14,6 +16,10 @@ interface KycState { diditSessionId: string | null; setDiditSessionId: (sessionId: string) => void; clearDiditSessionId: () => void; + /** Which product initiated KYC — drives post-KYC routing. */ + kycFlow: KycFlow | null; + setKycFlow: (flow: KycFlow) => void; + clearKycFlow: () => void; } const KYC_STORAGE_KEY = 'kyc-store'; @@ -24,6 +30,7 @@ export const useKycStore = create()( kycLinkId: null, processingUntil: null, diditSessionId: null, + kycFlow: null, setKycLinkId: (kycLinkId: string) => { set({ kycLinkId }); @@ -48,6 +55,14 @@ export const useKycStore = create()( clearDiditSessionId: () => { set({ diditSessionId: null }); }, + + setKycFlow: (flow: KycFlow) => { + set({ kycFlow: flow }); + }, + + clearKycFlow: () => { + set({ kycFlow: null }); + }, }), { name: KYC_STORAGE_KEY,