diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index f7a460a8a..a60e6da43 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -1,394 +1,37 @@ 'use client' -import ActionModal from '@/components/Global/ActionModal' -import AddressLink from '@/components/Global/AddressLink' +import { CryptoWithdrawFlow } from '@/components/Payment/flows' import PeanutLoading from '@/components/Global/PeanutLoading' -import DirectSuccessView from '@/components/Payment/Views/Status.payment.view' -import ConfirmWithdrawView from '@/components/Withdraw/views/Confirm.withdraw.view' -import InitialWithdrawView from '@/components/Withdraw/views/Initial.withdraw.view' -import { useWithdrawFlow, WithdrawData } from '@/context/WithdrawFlowContext' -import { InitiatePaymentPayload, usePaymentInitiator } from '@/hooks/usePaymentInitiator' -import { useWallet } from '@/hooks/wallet/useWallet' -import { useAppDispatch, usePaymentStore } from '@/redux/hooks' -import { paymentActions } from '@/redux/slices/payment-slice' -import { chargesApi } from '@/services/charges' -import { requestsApi } from '@/services/requests' -import { - CreateChargeRequest, - CreateRequestRequest as CreateRequestPayloadServices, - TCharge, - TRequestChargeResponse, - TRequestResponse, -} from '@/services/services.types' -import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' -import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' -import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, ROUTE_NOT_FOUND_ERROR } from '@/constants' +import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useRouter } from 'next/navigation' -import { useCallback, useEffect, useMemo } from 'react' -import { captureMessage } from '@sentry/nextjs' -import type { Address } from 'viem' -import { Slider } from '@/components/Slider' +import { useEffect } from 'react' export default function WithdrawCryptoPage() { const router = useRouter() - const dispatch = useAppDispatch() - const { chargeDetails: activeChargeDetailsFromStore } = usePaymentStore() - const { isConnected: isPeanutWallet, address } = useWallet() - const { - amountToWithdraw, - usdAmount, - setAmountToWithdraw, - currentView, - setCurrentView, - withdrawData, - setWithdrawData, - showCompatibilityModal, - setShowCompatibilityModal, - isPreparingReview, - setIsPreparingReview, - paymentError, - setPaymentError, - setError: setWithdrawError, - resetWithdrawFlow, - } = useWithdrawFlow() - - const { - initiatePayment, - isProcessing, - error: paymentErrorFromHook, - prepareTransactionDetails, - xChainRoute, - isCalculatingFees, - isPreparingTx, - reset: resetPaymentInitiator, - } = usePaymentInitiator() - - // Helper to manage errors consistently - const setError = useCallback( - (error: string | null) => { - setPaymentError(error) - dispatch(paymentActions.setError(error)) - // Also set the withdraw flow error state for display in InitialWithdrawView - setWithdrawError({ - showError: !!error, - errorMessage: error || '', - }) - }, - [setPaymentError, dispatch, setWithdrawError] - ) - - const clearErrors = useCallback(() => { - setError(null) - }, [setError]) - - useEffect(() => { - dispatch(paymentActions.resetPaymentState()) - resetPaymentInitiator() - }, [dispatch, resetPaymentInitiator]) + const { amountToWithdraw, resetWithdrawFlow } = useWithdrawFlow() + // Redirect if no amount is set useEffect(() => { if (!amountToWithdraw) { - console.error('Amount not available in WithdrawFlowContext for withdrawal, redirecting.') + console.error('Amount not available for crypto withdrawal, redirecting.') router.push('/withdraw') return } - clearErrors() - dispatch(paymentActions.setChargeDetails(null)) - }, [amountToWithdraw]) - - useEffect(() => { - setPaymentError(paymentErrorFromHook) - }, [paymentErrorFromHook]) - - useEffect(() => { - if (currentView === 'CONFIRM' && activeChargeDetailsFromStore && withdrawData) { - console.log('Preparing withdraw transaction details...') - console.dir(activeChargeDetailsFromStore) - prepareTransactionDetails({ - chargeDetails: activeChargeDetailsFromStore, - from: { - address: address as Address, - tokenAddress: PEANUT_WALLET_TOKEN, - chainId: PEANUT_WALLET_CHAIN.id.toString(), - }, - usdAmount: usdAmount, - }) - } - }, [currentView, activeChargeDetailsFromStore, withdrawData, prepareTransactionDetails, usdAmount, address]) - - const handleSetupReview = useCallback( - async (data: Omit) => { - if (!amountToWithdraw) { - console.error('Amount to withdraw is not set or not available from context') - setError('Withdrawal amount is missing.') - return - } - - clearErrors() - dispatch(paymentActions.setChargeDetails(null)) - setIsPreparingReview(true) - - try { - const completeWithdrawData = { ...data, amount: amountToWithdraw } - setWithdrawData(completeWithdrawData) - const apiRequestPayload: CreateRequestPayloadServices = { - recipientAddress: completeWithdrawData.address, - chainId: completeWithdrawData.chain.chainId.toString(), - tokenAddress: completeWithdrawData.token.address, - tokenType: String( - completeWithdrawData.token.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase() - ? peanutInterfaces.EPeanutLinkType.native - : peanutInterfaces.EPeanutLinkType.erc20 - ), - tokenAmount: amountToWithdraw, - tokenDecimals: completeWithdrawData.token.decimals.toString(), - tokenSymbol: completeWithdrawData.token.symbol, - } - const newRequest: TRequestResponse = await requestsApi.create(apiRequestPayload) - - if (!newRequest || !newRequest.uuid) { - throw new Error('Failed to create request for withdrawal.') - } - - const chargePayload: CreateChargeRequest = { - pricing_type: 'fixed_price', - local_price: { amount: completeWithdrawData.amount || amountToWithdraw, currency: 'USD' }, - baseUrl: window.location.origin, - requestId: newRequest.uuid, - requestProps: { - chainId: completeWithdrawData.chain.chainId.toString(), - tokenAmount: completeWithdrawData.amount, - tokenAddress: completeWithdrawData.token.address, - tokenType: - completeWithdrawData.token.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase() - ? peanutInterfaces.EPeanutLinkType.native - : peanutInterfaces.EPeanutLinkType.erc20, - tokenSymbol: completeWithdrawData.token.symbol, - tokenDecimals: Number(completeWithdrawData.token.decimals), - recipientAddress: completeWithdrawData.address, - }, - transactionType: 'WITHDRAW', - } - const createdCharge: TCharge = await chargesApi.create(chargePayload) - - if (!createdCharge || !createdCharge.data || !createdCharge.data.id) { - throw new Error('Failed to create charge for withdrawal or charge ID missing.') - } - - const fullChargeDetails: TRequestChargeResponse = await chargesApi.get(createdCharge.data.id) - - dispatch(paymentActions.setChargeDetails(fullChargeDetails)) - setShowCompatibilityModal(true) - } catch (err: any) { - console.error('Error during setup review (request/charge creation):', err) - const errorMessage = err.message || 'Could not prepare withdrawal. Please try again.' - setError(errorMessage) - } finally { - setIsPreparingReview(false) - } - }, - [amountToWithdraw, dispatch, setCurrentView] - ) - - const handleCompatibilityProceed = useCallback(() => { - setShowCompatibilityModal(false) - if (activeChargeDetailsFromStore && withdrawData) { - setCurrentView('CONFIRM') - } else { - console.error('Proceeding to confirm, but charge details or withdraw data are missing.') - setError('Failed to load withdrawal details for confirmation. Please go back and try again.') - } - }, [activeChargeDetailsFromStore, withdrawData, setCurrentView]) - - const handleConfirmWithdrawal = useCallback(async () => { - if (!activeChargeDetailsFromStore || !withdrawData || !amountToWithdraw) { - console.error('Withdraw data, active charge details, or amount missing for final confirmation') - setError('Essential withdrawal information is missing.') - return - } + }, [amountToWithdraw, router]) - clearErrors() - dispatch(paymentActions.setError(null)) - - const paymentPayload: InitiatePaymentPayload = { - recipient: { - identifier: withdrawData.address, - recipientType: 'ADDRESS', - resolvedAddress: withdrawData.address, - }, - tokenAmount: amountToWithdraw, - chargeId: activeChargeDetailsFromStore.uuid, - skipChargeCreation: true, - } - - const result = await initiatePayment(paymentPayload) - - if (result.success && result.txHash) { - setCurrentView('STATUS') - } else { - console.error('Withdrawal execution failed:', result.error) - const errMsg = result.error || 'Withdrawal processing failed.' - setError(errMsg) - } - }, [ - activeChargeDetailsFromStore, - withdrawData, - amountToWithdraw, - dispatch, - initiatePayment, - setCurrentView, - setAmountToWithdraw, - setWithdrawData, - setPaymentError, - ]) - - const handleRouteRefresh = useCallback(async () => { - if (!activeChargeDetailsFromStore) return - console.log('Refreshing withdraw route due to expiry...') - console.log('About to call prepareTransactionDetails with:', activeChargeDetailsFromStore) - await prepareTransactionDetails({ - chargeDetails: activeChargeDetailsFromStore, - from: { - address: address as Address, - tokenAddress: PEANUT_WALLET_TOKEN, - chainId: PEANUT_WALLET_CHAIN.id.toString(), - }, - usdAmount: usdAmount, - }) - }, [activeChargeDetailsFromStore, prepareTransactionDetails, usdAmount, address]) - - const handleBackFromConfirm = useCallback(() => { - setCurrentView('INITIAL') - clearErrors() - dispatch(paymentActions.setError(null)) - dispatch(paymentActions.setChargeDetails(null)) - }, [dispatch, setCurrentView]) - - // Check if this is a cross-chain withdrawal (align with usePaymentInitiator logic) - const isCrossChainWithdrawal = useMemo(() => { - if (!withdrawData || !activeChargeDetailsFromStore) return false - - // In withdraw flow, we're moving from Peanut Wallet to the selected chain - // This matches the logic in usePaymentInitiator for withdraw flows - const fromChainId = isPeanutWallet ? PEANUT_WALLET_CHAIN.id.toString() : withdrawData.chain.chainId - const toChainId = activeChargeDetailsFromStore.chainId - - return fromChainId !== toChainId - }, [withdrawData, activeChargeDetailsFromStore, isPeanutWallet]) - - // reset withdraw flow when this component unmounts - useEffect(() => { - return () => { - resetWithdrawFlow() - resetPaymentInitiator() - } - }, [resetWithdrawFlow, resetPaymentInitiator]) - - // Check for route type errors (similar to payment flow) - const routeTypeError = useMemo(() => { - if (!isCrossChainWithdrawal || !xChainRoute || !isPeanutWallet) return null - - // For peanut wallet flows, only RFQ routes are allowed - if (xChainRoute.type === 'swap') { - captureMessage('No RFQ route found for this token pair', { - level: 'warning', - extra: { - flow: 'withdraw', - routeObject: xChainRoute, - }, - }) - return ROUTE_NOT_FOUND_ERROR - } - - return null - }, [isCrossChainWithdrawal, xChainRoute, isPeanutWallet]) - - // Display payment errors first (user actions), then route errors (system limitations) - const displayError = paymentError ?? routeTypeError - - // Get network fee from route or fallback - const networkFee = useMemo(() => { - if (xChainRoute?.feeCostsUsd) { - return xChainRoute.feeCostsUsd - } - return 0 - }, [xChainRoute]) + const handleComplete = () => { + // Clean up the context state and go home + resetWithdrawFlow() + router.push('/home') + } if (!amountToWithdraw) { return } return ( -
- {currentView === 'INITIAL' && ( - router.back()} - isProcessing={isPreparingReview} - /> - )} - - {currentView === 'CONFIRM' && withdrawData && activeChargeDetailsFromStore && ( - - )} - - {currentView === 'STATUS' && withdrawData && activeChargeDetailsFromStore && ( - <> - - } - /> - - )} - - { - if (isPreparingReview) return - setShowCompatibilityModal(false) - }} - preventClose={isPreparingReview} - title="Is this address compatible?" - description="Only send to address that support the selected network and token. Incorrect transfers may be lost." - icon="alert" - footer={ -
- { - if (!v) return - handleCompatibilityProceed() - }} - /> -
- } - /> +
+
) } diff --git a/src/app/[...recipient]/client.tsx b/src/app/[...recipient]/client.tsx index 4bb989ee8..d19d325b6 100644 --- a/src/app/[...recipient]/client.tsx +++ b/src/app/[...recipient]/client.tsx @@ -6,6 +6,7 @@ import ConfirmPaymentView from '@/components/Payment/Views/Confirm.payment.view' import ValidationErrorView, { ValidationErrorViewProps } from '@/components/Payment/Views/Error.validation.view' import InitialPaymentView from '@/components/Payment/Views/Initial.payment.view' import DirectSuccessView from '@/components/Payment/Views/Status.payment.view' +import { RequestPayFlow } from '@/components/Payment/flows/RequestPayFlow' import PintaReqPaySuccessView from '@/components/PintaReqPay/Views/Success.pinta.view' import PublicProfile from '@/components/Profile/components/PublicProfile' import { TransactionDetailsReceipt } from '@/components/TransactionDetails/TransactionDetailsDrawer' @@ -393,7 +394,22 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props)
) } - // default payment flow + // modernized payment flow using TanStack Query architecture + if (flow === 'request_pay') { + return ( +
+ { + // Handle completion - could navigate or reset state + console.log('Request payment flow completed') + }} + /> +
+ ) + } + + // legacy flows (add_money, direct_pay, etc.) - keep using old system for now return (
{!user && parsedPaymentData?.recipient?.recipientType !== 'USERNAME' && ( diff --git a/src/app/send/[...username]/client.tsx b/src/app/send/[...username]/client.tsx new file mode 100644 index 000000000..f9b8eafce --- /dev/null +++ b/src/app/send/[...username]/client.tsx @@ -0,0 +1,101 @@ +'use client' + +import { DirectSendFlow } from '@/components/Payment/flows/DirectSendFlow' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { AccountType } from '@/interfaces' +import { ParsedURL } from '@/lib/url-parser/types/payment' +import { usersApi } from '@/services/users' +import { useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { isAddress } from 'viem' + +interface DirectSendPageProps { + recipient: string[] +} + +/** + * Client component for direct send flow + * Handles recipient resolution and renders DirectSendFlow + */ +export default function DirectSendPageClient({ recipient }: DirectSendPageProps) { + const router = useRouter() + const [parsedRecipient, setParsedRecipient] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const resolveRecipient = async () => { + try { + if (!recipient || recipient.length === 0) { + setError('No recipient specified') + return + } + + const recipientIdentifier = recipient[0] + + let resolvedRecipient: ParsedURL['recipient'] + + if (isAddress(recipientIdentifier)) { + // It's already a valid address + resolvedRecipient = { + identifier: recipientIdentifier, + resolvedAddress: recipientIdentifier, + recipientType: 'ADDRESS', + } + } else { + // It's a username - resolve it to an address + console.log('🔍 Resolving username:', recipientIdentifier) + const user = await usersApi.getByUsername(recipientIdentifier) + + // Find the Peanut wallet account (should be the primary one) + const peanutAccount = user.accounts.find((account) => account.type === AccountType.PEANUT_WALLET) + + if (!peanutAccount) { + throw new Error(`User ${recipientIdentifier} does not have a Peanut wallet`) + } + + resolvedRecipient = { + identifier: recipientIdentifier, + resolvedAddress: peanutAccount.identifier, // This should be the wallet address + recipientType: 'USERNAME', + } + + console.log('✅ Username resolved:', { + username: recipientIdentifier, + address: peanutAccount.identifier, + }) + } + + setParsedRecipient(resolvedRecipient) + } catch (err) { + console.error('Error resolving recipient:', err) + setError('Failed to resolve recipient') + } finally { + setIsLoading(false) + } + } + + resolveRecipient() + }, [recipient]) + + const handleComplete = () => { + router.push('/home') + } + + if (isLoading) { + return + } + + if (error || !parsedRecipient) { + return ( +
+
+

Error

+

{error || 'Invalid recipient'}

+
+
+ ) + } + + return +} diff --git a/src/app/send/[...username]/page.tsx b/src/app/send/[...username]/page.tsx index 1e7f78168..96e8e0403 100644 --- a/src/app/send/[...username]/page.tsx +++ b/src/app/send/[...username]/page.tsx @@ -1,4 +1,4 @@ -import PaymentPage from '@/app/[...recipient]/client' +import DirectSendPageClient from './client' import { generateMetadata as generateBaseMetadata } from '@/app/metadata' import PageContainer from '@/components/0_Bruddle/PageContainer' import { Metadata } from 'next' @@ -12,11 +12,9 @@ export default function DirectPaymentPage(props: PageProps) { const params = use(props.params) const usernameSegments = params.username ?? [] - const recipient = usernameSegments - return ( - + ) } diff --git a/src/components/Payment/FlowSelector.tsx b/src/components/Payment/FlowSelector.tsx new file mode 100644 index 000000000..a910286d9 --- /dev/null +++ b/src/components/Payment/FlowSelector.tsx @@ -0,0 +1,45 @@ +'use client' + +import { useDirectSendFlow, useAddMoneyFlow, useCryptoWithdrawFlow, useRequestPayFlow } from '@/hooks/payment' + +export type PaymentFlowType = 'direct_send' | 'add_money' | 'withdraw' | 'request_pay' + +/** + * Hook that returns the appropriate payment flow based on flow type + * This replaces the complex conditional logic in the old PaymentForm + */ +export const usePaymentFlow = (flowType: PaymentFlowType, chargeId?: string) => { + const directSendFlow = useDirectSendFlow() + const addMoneyFlow = useAddMoneyFlow() + const withdrawFlow = useCryptoWithdrawFlow() + const requestPayFlow = useRequestPayFlow(chargeId) + + switch (flowType) { + case 'direct_send': + return { + ...directSendFlow, + execute: directSendFlow.sendDirectly, + type: 'direct_send' as const, + } + case 'add_money': + return { + ...addMoneyFlow, + execute: addMoneyFlow.addMoney, + type: 'add_money' as const, + } + case 'withdraw': + return { + ...withdrawFlow, + execute: withdrawFlow.withdraw, + type: 'withdraw' as const, + } + case 'request_pay': + return { + ...requestPayFlow, + execute: requestPayFlow.payRequest, + type: 'request_pay' as const, + } + default: + throw new Error(`Unknown flow type: ${flowType}`) + } +} diff --git a/src/components/Payment/flows/CryptoWithdrawFlow.tsx b/src/components/Payment/flows/CryptoWithdrawFlow.tsx new file mode 100644 index 000000000..6eee4e6cc --- /dev/null +++ b/src/components/Payment/flows/CryptoWithdrawFlow.tsx @@ -0,0 +1,166 @@ +'use client' + +import { useCryptoWithdrawFlow } from '@/hooks/payment' +import { ITokenPriceData } from '@/interfaces' +import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' +import { useState, useCallback, useMemo } from 'react' +import { CryptoWithdrawInitial } from './views/CryptoWithdrawInitial' +import { CryptoWithdrawConfirm } from './views/CryptoWithdrawConfirm' +import { CryptoWithdrawStatus } from './views/CryptoWithdrawStatus' + +// Flow-specific types +interface CryptoWithdrawFormData { + amount: string + selectedToken: ITokenPriceData | null + selectedChain: (peanutInterfaces.ISquidChain & { tokens: peanutInterfaces.ISquidToken[] }) | null + recipientAddress: string + isValidRecipient: boolean +} + +interface CryptoWithdrawFlowProps { + initialAmount?: string + onComplete?: () => void +} + +type CryptoWithdrawView = 'INITIAL' | 'CONFIRM' | 'STATUS' + +/** + * CryptoWithdrawFlow Orchestrator + * + * Manages the complete crypto withdraw flow (Peanut → External crypto address): + * INITIAL → CONFIRM → STATUS + */ +export const CryptoWithdrawFlow = ({ initialAmount = '', onComplete }: CryptoWithdrawFlowProps) => { + // Simple UI state (orchestrator manages this) + const [currentView, setCurrentView] = useState('INITIAL') + const [formData, setFormData] = useState({ + amount: initialAmount, + selectedToken: null, + selectedChain: null, + recipientAddress: '', + isValidRecipient: false, + }) + + // Complex business logic state + const cryptoWithdrawHook = useCryptoWithdrawFlow() + + // Form data updater + const updateFormData = useCallback((updates: Partial) => { + setFormData((prev) => ({ ...prev, ...updates })) + }, []) + + // Create payload from form data + const currentPayload = useMemo(() => { + if ( + !formData.selectedToken || + !formData.selectedChain || + !formData.recipientAddress || + !formData.isValidRecipient || + !formData.amount || + parseFloat(formData.amount) <= 0 + ) { + return null + } + + return { + recipient: { + identifier: formData.recipientAddress, + resolvedAddress: formData.recipientAddress, + recipientType: 'ADDRESS' as const, + }, + tokenAmount: formData.amount, + toChainId: formData.selectedChain.chainId, + toTokenAddress: formData.selectedToken.address, + } + }, [formData]) + + // View navigation handlers + const handleNext = useCallback(async () => { + if (currentView === 'INITIAL') { + // Validate form + if (!currentPayload) { + console.error('Form validation failed - missing required fields') + return + } + + // Prepare route (synchronous now with TanStack Query!) + console.log('Preparing route for withdrawal...') + cryptoWithdrawHook.prepareRoute(currentPayload) + + // Move to confirm immediately - route preparation happens in background + console.log('Moving to confirm view, route will prepare automatically') + setCurrentView('CONFIRM') + } else if (currentView === 'CONFIRM') { + // Execute the crypto withdraw transaction with cached route + console.log('Executing withdrawal with cached route...') + const result = await cryptoWithdrawHook.withdraw() + + if (result.success) { + console.log('Withdrawal successful, moving to status view') + setCurrentView('STATUS') + } else { + console.error('Withdrawal failed:', result.error) + // Error will be shown by the hook + } + } + }, [currentView, currentPayload, cryptoWithdrawHook]) + + const handleBack = useCallback(() => { + if (currentView === 'CONFIRM') { + setCurrentView('INITIAL') + } else if (currentView === 'STATUS') { + // Reset everything and start over + setCurrentView('INITIAL') + setFormData({ + amount: initialAmount, + selectedToken: null, + selectedChain: null, + recipientAddress: '', + isValidRecipient: false, + }) + cryptoWithdrawHook.reset() + } + }, [currentView, initialAmount, cryptoWithdrawHook.reset]) + + const handleComplete = useCallback(() => { + onComplete?.() + // Note: Component will unmount and all state will be cleaned up automatically! + }, [onComplete]) + + // Render the appropriate view + if (currentView === 'INITIAL') { + return ( + + ) + } + + if (currentView === 'CONFIRM') { + return ( + + ) + } + + if (currentView === 'STATUS') { + return ( + + ) + } + + return null +} diff --git a/src/components/Payment/flows/DirectSendFlow.tsx b/src/components/Payment/flows/DirectSendFlow.tsx new file mode 100644 index 000000000..ca181e2a2 --- /dev/null +++ b/src/components/Payment/flows/DirectSendFlow.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useDirectSendFlow } from '@/hooks/payment' +import { ParsedURL } from '@/lib/url-parser/types/payment' +import { useState, useCallback } from 'react' +import { DirectSendInitial } from './views/DirectSendInitial' + +import { DirectSendStatus } from './views/DirectSendStatus' + +// Flow-specific types +interface DirectSendFormData { + amount: string + message: string + recipient: ParsedURL['recipient'] | null +} + +interface DirectSendFlowProps { + recipient?: ParsedURL['recipient'] + initialAmount?: string + onComplete?: () => void +} + +type DirectSendView = 'INITIAL' | 'STATUS' + +/** + * DirectSendFlow Orchestrator + * + * Manages the complete direct send flow (Peanut → Peanut, USDC only): + * INITIAL → STATUS (no confirmation needed for direct USDC transfers) + * + * Key benefits: + * - Clean state management (no Redux!) + * - State resets on unmount (fresh start every time) + * - Flow-specific logic isolated from other flows + * - Reuses existing shared UI components + */ +export const DirectSendFlow = ({ recipient, initialAmount = '', onComplete }: DirectSendFlowProps) => { + // Simple UI state (orchestrator manages this) + const [currentView, setCurrentView] = useState('INITIAL') + const [formData, setFormData] = useState({ + amount: initialAmount, + message: '', + recipient: recipient || null, + }) + + // Complex business logic state (hook manages this) + const directSendHook = useDirectSendFlow() + + // Form data updater + const updateFormData = useCallback((updates: Partial) => { + setFormData((prev) => ({ ...prev, ...updates })) + }, []) + + // View navigation handlers + const handleNext = useCallback(async () => { + if (currentView === 'INITIAL') { + // Validate form before proceeding + if (!formData.recipient?.resolvedAddress) { + console.error('Recipient is required') + return + } + if (!formData.amount || parseFloat(formData.amount) <= 0) { + console.error('Valid amount is required') + return + } + + // Execute the direct send transaction immediately (no confirmation needed) + const result = await directSendHook.sendDirectly({ + recipient: { + identifier: formData.recipient!.identifier, + resolvedAddress: formData.recipient!.resolvedAddress, + }, + tokenAmount: formData.amount, + attachmentOptions: formData.message ? { message: formData.message } : undefined, + }) + + if (result.success) { + setCurrentView('STATUS') + } + // If error, stay on INITIAL view - error will be shown by the hook + } + }, [currentView, formData, directSendHook]) + + const handleBack = useCallback(() => { + if (currentView === 'STATUS') { + // Reset everything and start over + setCurrentView('INITIAL') + setFormData({ + amount: '', + message: '', + recipient: recipient || null, + }) + directSendHook.reset() + } + }, [currentView, recipient, directSendHook]) + + const handleComplete = useCallback(() => { + onComplete?.() + // Note: Component will unmount and all state will be cleaned up automatically! + }, [onComplete]) + + // Render the appropriate view + if (currentView === 'INITIAL') { + return ( + + ) + } + + if (currentView === 'STATUS') { + return ( + + ) + } + + return null +} diff --git a/src/components/Payment/flows/RequestPayFlow.tsx b/src/components/Payment/flows/RequestPayFlow.tsx new file mode 100644 index 000000000..a97a16729 --- /dev/null +++ b/src/components/Payment/flows/RequestPayFlow.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useState } from 'react' +import { useSearchParams } from 'next/navigation' +import { useRequestPayFlow } from '@/hooks/payment/useRequestPayFlow' +import type { RequestPayPayload } from '@/hooks/payment/types' + +// Import view components +import { RequestPayInitial } from '@/components/Payment/flows/views/RequestPayInitial' +import { RequestPayConfirm } from '@/components/Payment/flows/views/RequestPayConfirm' +import { RequestPayStatus } from '@/components/Payment/flows/views/RequestPayStatus' + +export type RequestPayFlowView = 'initial' | 'confirm' | 'status' + +interface RequestPayFlowProps { + recipient?: string[] + onComplete?: () => void +} + +/** + * RequestPayFlow - Clean orchestrator for request payment flow + * + * Modernized with TanStack Query approach: + * - Automatic caching and deduplication + * - Built-in retry logic and error handling + * - Race condition elimination + * - Better loading states and UX + * + * Follows the same pattern as CryptoWithdrawFlow and DirectSendFlow + * but keeps the UI exactly the same as the legacy PaymentForm system. + */ +export const RequestPayFlow = ({ recipient, onComplete }: RequestPayFlowProps) => { + const searchParams = useSearchParams() + const chargeId = searchParams.get('chargeId') + const requestId = searchParams.get('id') + + // Local view state management (no Redux!) + const [currentView, setCurrentView] = useState('initial') + const [paymentPayload, setPaymentPayload] = useState(null) + const [isCreatingCharge, setIsCreatingCharge] = useState(false) + + // Use chargeId from payload (dynamic creation) or URL (existing charge) + const effectiveChargeId = paymentPayload?.chargeId || chargeId || undefined + + // TanStack Query powered hook + const { + payRequest, + createCharge, + isProcessing, + isPreparingRoute, + error, + chargeDetails, + route, + transactionHash, + estimatedFees, + isLoadingCharge, + isLoadingRoute, + reset, + } = useRequestPayFlow(effectiveChargeId) + + const handleInitialSubmit = async (payload: RequestPayPayload) => { + try { + let finalPayload = payload + + // For dynamic scenarios (no existing charge), create charge before moving to confirm + if (!payload.chargeId && payload.recipient && payload.selectedTokenAddress && payload.selectedChainID) { + setIsCreatingCharge(true) + + const newCharge = await createCharge(payload) + + // Update payload with the new charge ID + finalPayload = { + ...payload, + chargeId: newCharge.uuid, + } + } + + setPaymentPayload(finalPayload) + setCurrentView('confirm') + } catch (error) { + console.error('❌ Failed to create charge:', error) + // Error will be handled by the hook and displayed in the UI + } finally { + setIsCreatingCharge(false) + } + } + + const handleConfirmSubmit = async () => { + if (!paymentPayload) return + + const result = await payRequest(paymentPayload) + + if (result.success) { + setCurrentView('status') + } + // Error handling is done by the hook via TanStack Query + } + + const handleGoBack = () => { + if (currentView === 'confirm') { + setCurrentView('initial') + } else if (currentView === 'status') { + setCurrentView('initial') + reset() + onComplete?.() + } + } + + const handleRetry = () => { + if (currentView === 'status') { + setCurrentView('confirm') + } + } + + // Show loading while fetching initial charge data + if (isLoadingCharge) { + return
Loading...
+ } + + // Render appropriate view + switch (currentView) { + case 'initial': + return ( + + ) + + case 'confirm': + return ( + + ) + + case 'status': + return ( + + ) + + default: + return null + } +} diff --git a/src/components/Payment/flows/index.ts b/src/components/Payment/flows/index.ts new file mode 100644 index 000000000..c1ba4a12e --- /dev/null +++ b/src/components/Payment/flows/index.ts @@ -0,0 +1,19 @@ +// Payment Flow Orchestrators +export { DirectSendFlow } from './DirectSendFlow' +export { CryptoWithdrawFlow } from './CryptoWithdrawFlow' +export { RequestPayFlow } from './RequestPayFlow' + +// DirectSend Flow View Components +export { DirectSendInitial } from './views/DirectSendInitial' +export { DirectSendConfirm } from './views/DirectSendConfirm' +export { DirectSendStatus } from './views/DirectSendStatus' + +// CryptoWithdraw Flow View Components +export { CryptoWithdrawInitial } from './views/CryptoWithdrawInitial' +export { CryptoWithdrawConfirm } from './views/CryptoWithdrawConfirm' +export { CryptoWithdrawStatus } from './views/CryptoWithdrawStatus' + +// RequestPay Flow View Components +export { RequestPayInitial } from './views/RequestPayInitial' +export { RequestPayConfirm } from './views/RequestPayConfirm' +export { RequestPayStatus } from './views/RequestPayStatus' diff --git a/src/components/Payment/flows/views/CryptoWithdrawConfirm.tsx b/src/components/Payment/flows/views/CryptoWithdrawConfirm.tsx new file mode 100644 index 000000000..343ddb285 --- /dev/null +++ b/src/components/Payment/flows/views/CryptoWithdrawConfirm.tsx @@ -0,0 +1,244 @@ +'use client' + +import { Button } from '@/components/0_Bruddle' +import AddressLink from '@/components/Global/AddressLink' +import Card from '@/components/Global/Card' +import DisplayIcon from '@/components/Global/DisplayIcon' +import ErrorAlert from '@/components/Global/ErrorAlert' +import NavHeader from '@/components/Global/NavHeader' +import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { useCryptoWithdrawFlow } from '@/hooks/payment' +import { useTokenChainIcons } from '@/hooks/useTokenChainIcons' +import { ITokenPriceData } from '@/interfaces' +import { formatAmount } from '@/utils' +import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' +import { useCallback, useEffect, useMemo } from 'react' +import { ROUTE_NOT_FOUND_ERROR } from '@/constants' + +interface CryptoWithdrawFormData { + amount: string + selectedToken: ITokenPriceData | null + selectedChain: (peanutInterfaces.ISquidChain & { tokens: peanutInterfaces.ISquidToken[] }) | null + recipientAddress: string + isValidRecipient: boolean +} + +interface CryptoWithdrawConfirmProps { + formData: CryptoWithdrawFormData + cryptoWithdrawHook: ReturnType + onNextAction: () => void + onBackAction: () => void +} + +/** + * Enhanced CryptoWithdrawConfirm View + * + * The confirmation step for crypto withdraw flow with full feature parity: + * - Shows transaction details with actual route data + * - Displays proper min received from route + * - Timer functionality for route expiry + * - Route refresh capability + * - Enhanced error handling and retry logic + */ +export const CryptoWithdrawConfirm = ({ + formData, + cryptoWithdrawHook, + onNextAction, + onBackAction, +}: CryptoWithdrawConfirmProps) => { + const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({ + chainId: formData.selectedChain?.chainId, + tokenAddress: formData.selectedToken?.address, + tokenSymbol: formData.selectedToken?.symbol, + }) + + const { + isProcessing, + isPreparingRoute, + displayError, + routeError, + routeExpiry, + isRouteExpired, + minReceived, + estimatedFees, + isCrossChain, + refreshRoute, + isStale, // TanStack Query feature - indicates if data might be outdated + isFetching, // TanStack Query feature - indicates if currently fetching + } = cryptoWithdrawHook + + useEffect(() => { + if (!routeExpiry) return + + const interval = setInterval(() => { + const expiryTime = new Date(routeExpiry).getTime() + const currentTime = Date.now() + const remaining = Math.max(0, expiryTime - currentTime) + + if (remaining === 0) { + clearInterval(interval) + } + }, 1000) + + return () => clearInterval(interval) + }, [routeExpiry]) + + // Route refresh handler - simplified with TanStack Query + const handleRouteRefresh = useCallback(async () => { + console.log('Refreshing route...') + await refreshRoute() + }, [refreshRoute]) + + const networkFeeDisplay = useMemo(() => { + const fee = estimatedFees || 0 + if (fee < 0.01) return 'Sponsored by Peanut!' + return ( + <> + $ {fee.toFixed(2)} + {' – '} + Sponsored by Peanut! + + ) + }, [estimatedFees]) + + return ( +
+ + +
+ {/* Enhanced Amount Display Card with Timer */} + { + handleRouteRefresh() + }} + disableTimerRefetch={isProcessing} + timerError={routeError === ROUTE_NOT_FOUND_ERROR ? routeError : null} + /> + + {/* Enhanced Transaction Details Card */} + + {minReceived && ( + + )} + + + {formData.selectedToken && ( +
+ + {chainIconUrl && ( +
+ +
+ )} +
+ )} + + {resolvedTokenSymbol || formData.selectedToken?.symbol} on{' '} + + {resolvedChainName || formData.selectedChain?.axelarChainName} + + +
+ } + /> + + + } + /> + + + + + + {/* Enhanced Action Button with Error Handling */} + {displayError ? ( + + ) : ( + + )} + + {/* Enhanced Error Display */} + {displayError && ( + + )} +
+
+ ) +} diff --git a/src/components/Payment/flows/views/CryptoWithdrawInitial.tsx b/src/components/Payment/flows/views/CryptoWithdrawInitial.tsx new file mode 100644 index 000000000..ada322e5d --- /dev/null +++ b/src/components/Payment/flows/views/CryptoWithdrawInitial.tsx @@ -0,0 +1,143 @@ +'use client' + +import { Button } from '@/components/0_Bruddle' +import ErrorAlert from '@/components/Global/ErrorAlert' +import GeneralRecipientInput, { GeneralRecipientUpdate } from '@/components/Global/GeneralRecipientInput' +import NavHeader from '@/components/Global/NavHeader' +import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' +import TokenSelector from '@/components/Global/TokenSelector/TokenSelector' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' +import { tokenSelectorContext } from '@/context/tokenSelector.context' +import { ITokenPriceData } from '@/interfaces' +import { formatAmount } from '@/utils/general.utils' +import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' +import { useRouter } from 'next/navigation' +import { useContext, useEffect, useMemo } from 'react' + +interface CryptoWithdrawFormData { + amount: string + selectedToken: ITokenPriceData | null + selectedChain: (peanutInterfaces.ISquidChain & { tokens: peanutInterfaces.ISquidToken[] }) | null + recipientAddress: string + isValidRecipient: boolean +} + +interface CryptoWithdrawInitialProps { + formData: CryptoWithdrawFormData + updateFormDataAction: (updates: Partial) => void + onNextAction: () => void + isProcessing: boolean + error: string | null +} + +/** + * CryptoWithdrawInitial View + * + * The initial step for crypto withdraw flow: + * - Shows amount (from previous step) + * - Token/chain selector + * - Recipient address input + * - Review button + * + * Reuses existing components - following DirectSend pattern! + */ +export const CryptoWithdrawInitial = ({ + formData, + updateFormDataAction, + onNextAction, + isProcessing, + error, +}: CryptoWithdrawInitialProps) => { + const router = useRouter() + const { + selectedTokenData, + selectedChainID, + supportedSquidChainsAndTokens, + setSelectedChainID, + setSelectedTokenAddress, + } = useContext(tokenSelectorContext) + + // Initialize with Peanut wallet defaults (like existing implementation) + useEffect(() => { + setSelectedChainID(PEANUT_WALLET_CHAIN.id.toString()) + setSelectedTokenAddress(PEANUT_WALLET_TOKEN) + }, [setSelectedChainID, setSelectedTokenAddress]) + + // Update form data when token selector changes + useEffect(() => { + if (selectedTokenData && supportedSquidChainsAndTokens[selectedChainID]) { + updateFormDataAction({ + selectedToken: selectedTokenData, + selectedChain: supportedSquidChainsAndTokens[selectedChainID], + }) + } + }, [selectedTokenData, selectedChainID, supportedSquidChainsAndTokens, updateFormDataAction]) + + // Validation + const canProceed = useMemo(() => { + return ( + formData.selectedToken && + formData.selectedChain && + formData.recipientAddress && + formData.isValidRecipient && + formData.amount && + parseFloat(formData.amount) > 0 && + !isProcessing + ) + }, [formData, isProcessing]) + + const handleRecipientUpdate = (update: GeneralRecipientUpdate) => { + updateFormDataAction({ + recipientAddress: update.recipient.address, + isValidRecipient: update.isValid, + }) + } + + return ( +
+ router.back()} /> + +
+ {/* Amount Display Card - Reusing existing component! */} + + + {/* Token/Chain Selector - Reusing existing component! */} + + + {/* Recipient Address Input - Reusing existing component! */} + + + {/* Review Button */} + + + {/* Error Display - Reusing existing component! */} + {error && } +
+
+ ) +} diff --git a/src/components/Payment/flows/views/CryptoWithdrawStatus.tsx b/src/components/Payment/flows/views/CryptoWithdrawStatus.tsx new file mode 100644 index 000000000..c893874f5 --- /dev/null +++ b/src/components/Payment/flows/views/CryptoWithdrawStatus.tsx @@ -0,0 +1,51 @@ +'use client' + +import AddressLink from '@/components/Global/AddressLink' +import DirectSuccessView from '@/components/Payment/Views/Status.payment.view' +import { useCryptoWithdrawFlow } from '@/hooks/payment' +import { ITokenPriceData } from '@/interfaces' +import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' + +interface CryptoWithdrawFormData { + amount: string + selectedToken: ITokenPriceData | null + selectedChain: (peanutInterfaces.ISquidChain & { tokens: peanutInterfaces.ISquidToken[] }) | null + recipientAddress: string + isValidRecipient: boolean +} + +interface CryptoWithdrawStatusProps { + formData: CryptoWithdrawFormData + cryptoWithdrawHook: ReturnType + onCompleteAction: () => void + onWithdrawAnotherAction: () => void +} + +/** + * CryptoWithdrawStatus View + * + * The success step for crypto withdraw flow - uses DirectSuccessView to match original design exactly. + */ +export const CryptoWithdrawStatus = ({ + formData, + cryptoWithdrawHook, + onCompleteAction, + onWithdrawAnotherAction, +}: CryptoWithdrawStatusProps) => { + return ( + + } + /> + ) +} diff --git a/src/components/Payment/flows/views/DirectSendConfirm.tsx b/src/components/Payment/flows/views/DirectSendConfirm.tsx new file mode 100644 index 000000000..6dbaa88c5 --- /dev/null +++ b/src/components/Payment/flows/views/DirectSendConfirm.tsx @@ -0,0 +1,121 @@ +'use client' + +import { Button } from '@/components/0_Bruddle' +import Card from '@/components/Global/Card' +import ErrorAlert from '@/components/Global/ErrorAlert' +import NavHeader from '@/components/Global/NavHeader' +import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' +import UserCard from '@/components/User/UserCard' +import { useDirectSendFlow } from '@/hooks/payment' +import { ParsedURL } from '@/lib/url-parser/types/payment' +import { formatAmount } from '@/utils' + +interface DirectSendFormData { + amount: string + message: string + recipient: ParsedURL['recipient'] | null +} + +interface DirectSendConfirmProps { + formData: DirectSendFormData + directSendHook: ReturnType + onNextAction: () => void + onBackAction: () => void +} + +/** + * DirectSendConfirm View + * + * The confirmation step for direct send flow: + * - Shows transaction details + * - Displays fees (none for USDC on Arbitrum) + * - Confirm button to execute transaction + * - Back button to edit details + * + * Uses existing shared components for consistency! + */ +export const DirectSendConfirm = ({ formData, directSendHook, onNextAction, onBackAction }: DirectSendConfirmProps) => { + const { isProcessing, error } = directSendHook + + return ( +
+ {/* Navigation Header */} + + +
+ {/* Recipient Card - Reusing existing component! */} + {formData.recipient && ( + + )} + + {/* Transaction Details Card - Reusing existing component! */} + + + {/* Fee Information */} + +
+ Network Fee + Free +
+
+ Peanut Fee + Free +
+
+
+ Total + ${formatAmount(formData.amount)} USDC +
+
+ + {/* Action Buttons */} +
+ + + {/* Back Button */} + + + {/* Error Display - Reusing existing component! */} + {error && } +
+ + {/* Info Message */} +
+ 💡 USDC transfers on Arbitrum are free and instant! +
+
+
+ ) +} diff --git a/src/components/Payment/flows/views/DirectSendInitial.tsx b/src/components/Payment/flows/views/DirectSendInitial.tsx new file mode 100644 index 000000000..b351cd96b --- /dev/null +++ b/src/components/Payment/flows/views/DirectSendInitial.tsx @@ -0,0 +1,126 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useMemo } from 'react' + +import { Button } from '@/components/0_Bruddle' +import BaseInput from '@/components/0_Bruddle/BaseInput' +import ErrorAlert from '@/components/Global/ErrorAlert' +import NavHeader from '@/components/Global/NavHeader' +import TokenAmountInput from '@/components/Global/TokenAmountInput' +import UserCard from '@/components/User/UserCard' +import { ParsedURL } from '@/lib/url-parser/types/payment' +import { useWallet } from '@/hooks/wallet/useWallet' +import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' +import { formatAmount } from '@/utils' +import { formatUnits } from 'viem' + +interface DirectSendFormData { + amount: string + message: string + recipient: ParsedURL['recipient'] | null +} + +interface DirectSendInitialProps { + formData: DirectSendFormData + updateFormDataAction: (updates: Partial) => void + onNextAction: () => void + isProcessing: boolean + error: string | null +} + +/** + * DirectSendInitial View + * + * The initial step for direct send flow: + * - Shows recipient card + * - Amount input (USDC only) + * - Optional message input + * - Send button + * + * Uses existing shared components - no new UI needed! + */ +export const DirectSendInitial = ({ + formData, + updateFormDataAction, + onNextAction, + isProcessing, + error, +}: DirectSendInitialProps) => { + const router = useRouter() + const { balance } = useWallet() + + // Validation + const canProceed = useMemo(() => { + return ( + formData.recipient?.resolvedAddress && formData.amount && parseFloat(formData.amount) > 0 && !isProcessing + ) + }, [formData.recipient, formData.amount, isProcessing]) + + const handleAmountChange = (value: string | undefined) => { + updateFormDataAction({ amount: value || '' }) + } + + const handleMessageChange = (value: string) => { + updateFormDataAction({ message: value }) + } + + return ( +
+ {/* Navigation Header */} + + +
+ {/* Recipient Card - Reusing existing component! */} + {formData.recipient && ( + + )} + + {/* Amount Input - Reusing existing component! */} + + + {/* Message Input - Using same pattern as FileUploadInput */} + handleMessageChange(e.target.value)} + className="w-full" + disabled={isProcessing} + maxLength={140} + /> + + {/* Action Button */} +
+ + + {/* Error Display - Reusing existing component! */} + {error && } +
+
+
+ ) +} diff --git a/src/components/Payment/flows/views/DirectSendStatus.tsx b/src/components/Payment/flows/views/DirectSendStatus.tsx new file mode 100644 index 000000000..9dea2765f --- /dev/null +++ b/src/components/Payment/flows/views/DirectSendStatus.tsx @@ -0,0 +1,139 @@ +'use client' + +import { Button } from '@/components/0_Bruddle' +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import NavHeader from '@/components/Global/NavHeader' +import { TransactionDetailsDrawer } from '@/components/TransactionDetails/TransactionDetailsDrawer' +import { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { BASE_URL } from '@/constants' +import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' +import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory' +import { useDirectSendFlow } from '@/hooks/payment' +import { ParsedURL } from '@/lib/url-parser/types/payment' +import { formatAmount, getInitialsFromName } from '@/utils' +import { useRouter } from 'next/navigation' +import { useMemo } from 'react' + +interface DirectSendFormData { + amount: string + message: string + recipient: ParsedURL['recipient'] | null +} + +interface DirectSendStatusProps { + formData: DirectSendFormData + directSendHook: ReturnType + onCompleteAction: () => void + onSendAnotherAction: () => void +} + +/** + * DirectSendStatus View + * + * Matches the existing DirectSuccessView design exactly - no visual changes! + */ +export const DirectSendStatus = ({ formData, directSendHook, onCompleteAction }: DirectSendStatusProps) => { + const router = useRouter() + const { isDrawerOpen, selectedTransaction, openTransactionDetails, closeTransactionDetails } = + useTransactionDetailsDrawer() + + const handleDone = () => { + router.push('/home') + onCompleteAction() + } + + // Construct transaction details for the drawer (same as original DirectSuccessView) + const transactionForDrawer: TransactionDetails | null = useMemo(() => { + const { chargeDetails, paymentDetails } = directSendHook + if (!chargeDetails) return null + + const networkFeeDisplayValue = '$ 0.00' // fee is zero for peanut wallet txns + const peanutFeeDisplayValue = '$ 0.00' // peanut doesn't charge fees yet + + const recipientIdentifier = formData.recipient?.identifier + const receiptLink = recipientIdentifier + ? `${BASE_URL}/${recipientIdentifier}?chargeId=${chargeDetails.uuid}` + : undefined + + const details: Partial = { + id: paymentDetails?.payerTransactionHash, + txHash: paymentDetails?.payerTransactionHash, + status: 'completed', + amount: parseFloat(formData.amount), + date: new Date(paymentDetails?.createdAt ?? chargeDetails.createdAt), + tokenSymbol: chargeDetails.tokenSymbol, + direction: 'send', + initials: getInitialsFromName(recipientIdentifier || ''), + extraDataForDrawer: { + isLinkTransaction: false, + originalType: EHistoryEntryType.DIRECT_SEND, + originalUserRole: EHistoryUserRole.SENDER, + link: receiptLink, + }, + userName: recipientIdentifier, + sourceView: 'status', + memo: formData.message || undefined, + attachmentUrl: chargeDetails.requestLink?.attachmentUrl || undefined, + networkFeeDetails: { + amountDisplay: networkFeeDisplayValue, + moreInfoText: 'This transaction may face slippage due to token conversion or cross-chain bridging.', + }, + peanutFeeDetails: { + amountDisplay: peanutFeeDisplayValue, + }, + } + + return details as TransactionDetails + }, [directSendHook, formData]) + + return ( +
+
+ +
+ +
+ +
+
+ +
+
+ +
+

You sent {formData.recipient?.identifier}

+

$ {formatAmount(formData.amount)}

+ {formData.message &&

for {formData.message}

} +
+
+ +
+ + + +
+
+ + {/* Transaction Details Drawer */} + +
+ ) +} diff --git a/src/components/Payment/flows/views/RequestPayConfirm.tsx b/src/components/Payment/flows/views/RequestPayConfirm.tsx new file mode 100644 index 000000000..0a737bbfb --- /dev/null +++ b/src/components/Payment/flows/views/RequestPayConfirm.tsx @@ -0,0 +1,300 @@ +'use client' + +import { useMemo, useContext } from 'react' +import { Button } from '@/components/0_Bruddle' +import NavHeader from '@/components/Global/NavHeader' +import ErrorAlert from '@/components/Global/ErrorAlert' +import Card from '@/components/Global/Card' +import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' +import ActionModal from '@/components/Global/ActionModal' +import { PaymentInfoRow } from '../../PaymentInfoRow' +import { useTokenChainIcons } from '@/hooks/useTokenChainIcons' +import { useWallet } from '@/hooks/wallet/useWallet' +import { useAccount } from 'wagmi' +import { tokenSelectorContext } from '@/context' +import { formatAmount, areEvmAddressesEqual, isStableCoin } from '@/utils' +import { formatUnits } from 'viem' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants' +import type { TRequestChargeResponse } from '@/services/services.types' +import type { RequestPayPayload } from '@/hooks/payment/types' +import type { PeanutCrossChainRoute } from '@/services/swap' + +interface RequestPayConfirmProps { + payload: RequestPayPayload + chargeDetails?: TRequestChargeResponse | null + route?: PeanutCrossChainRoute | null + estimatedFees?: number + isProcessing: boolean + isPreparingRoute: boolean + error?: string | null + onConfirm: () => void + onBack: () => void +} + +/** + * RequestPayConfirm - Confirmation view for request payment flow + * + * This component maintains the exact same UI as the legacy ConfirmPaymentView + * but uses modern TanStack Query architecture underneath. + * + * Features: + * - Transaction details display + * - Fee estimation + * - Cross-chain route information + * - Loading states during preparation + * - External wallet confirmation modal + */ +export const RequestPayConfirm = ({ + payload, + chargeDetails, + route, + estimatedFees, + isProcessing, + isPreparingRoute, + error, + onConfirm, + onBack, +}: RequestPayConfirmProps) => { + const { isConnected: isPeanutWallet } = useWallet() + const { isConnected: isExternalWallet } = useAccount() + const { selectedTokenData, selectedChainID } = useContext(tokenSelectorContext) + + const isUsingExternalWallet = !isPeanutWallet + + // Token and chain icons for sending token + const { + tokenIconUrl: sendingTokenIconUrl, + chainIconUrl: sendingChainIconUrl, + resolvedChainName: sendingResolvedChainName, + resolvedTokenSymbol: sendingResolvedTokenSymbol, + } = useTokenChainIcons({ + chainId: isUsingExternalWallet ? selectedChainID : PEANUT_WALLET_CHAIN.id.toString(), + tokenAddress: isUsingExternalWallet ? selectedTokenData?.address : PEANUT_WALLET_TOKEN, + tokenSymbol: isUsingExternalWallet ? selectedTokenData?.symbol : PEANUT_WALLET_TOKEN_SYMBOL, + }) + + // Token and chain icons for requested token + const { + tokenIconUrl: requestedTokenIconUrl, + chainIconUrl: requestedChainIconUrl, + resolvedChainName: requestedResolvedChainName, + resolvedTokenSymbol: requestedResolvedTokenSymbol, + } = useTokenChainIcons({ + chainId: chargeDetails?.chainId, + tokenAddress: chargeDetails?.tokenAddress, + tokenSymbol: chargeDetails?.tokenSymbol, + }) + + // Network fee calculation + const networkFee = useMemo(() => { + if (estimatedFees === undefined) { + return isUsingExternalWallet ? '-' : 'Sponsored by Peanut!' + } + + // External wallet flows + if (isUsingExternalWallet) { + return estimatedFees < 0.01 ? '$ <0.01' : `$ ${estimatedFees.toFixed(2)}` + } + + // Peanut-sponsored transactions + if (estimatedFees < 0.01) return 'Sponsored by Peanut!' + + return ( + <> + $ {estimatedFees.toFixed(2)} + {' - '} + Sponsored by Peanut! + + ) + }, [estimatedFees, isUsingExternalWallet]) + + // Determine if this is a cross-chain payment + const isCrossChainPayment = useMemo((): boolean => { + if (!chargeDetails) return false + if (!isUsingExternalWallet) { + return ( + !areEvmAddressesEqual(chargeDetails.tokenAddress, PEANUT_WALLET_TOKEN) || + chargeDetails.chainId !== PEANUT_WALLET_CHAIN.id.toString() + ) + } else if (selectedTokenData && selectedChainID) { + return ( + !areEvmAddressesEqual(chargeDetails.tokenAddress, selectedTokenData.address) || + chargeDetails.chainId !== selectedChainID + ) + } + return false + }, [chargeDetails, selectedTokenData, selectedChainID, isUsingExternalWallet]) + + // Calculate minimum received amount + const minReceived = useMemo(() => { + if (!chargeDetails?.tokenDecimals || !requestedResolvedTokenSymbol) return null + if (!route) { + return `$ ${chargeDetails?.tokenAmount}` + } + const amount = formatAmount( + formatUnits(BigInt(route.rawResponse.route.estimate.toAmountMin), chargeDetails.tokenDecimals) + ) + return isStableCoin(requestedResolvedTokenSymbol) ? `$ ${amount}` : `${amount} ${requestedResolvedTokenSymbol}` + }, [route, chargeDetails?.tokenDecimals, requestedResolvedTokenSymbol]) + + // Show external wallet confirmation modal + const showExternalWalletModal = useMemo((): boolean => { + return isProcessing && isUsingExternalWallet + }, [isProcessing, isUsingExternalWallet]) + + if (!chargeDetails) { + return ( +
+ +
+ ) + } + + return ( +
+ + +
+ {/* Payment Details Card */} + + + {/* Transaction Details */} + + + + {isCrossChainPayment && ( + + } + /> + )} + + + } + /> + + + + + + + {/* Action Button */} +
+ + + {error && ( +
+ +
+ )} +
+ + {/* External Wallet Confirmation Modal */} + {}} // Prevent closing during transaction + title="Continue in your wallet" + description="Please confirm the transaction in your wallet app to proceed." + isLoadingIcon={true} + preventClose={true} + /> +
+
+ ) +} + +interface TokenChainInfoDisplayProps { + tokenIconUrl?: string + chainIconUrl?: string + resolvedTokenSymbol?: string + fallbackTokenSymbol: string + resolvedChainName?: string + fallbackChainName: string +} + +/** + * Displays token and chain information with icons and names. + * Shows token icon with chain icon as a badge overlay, along with formatted text. + */ +function TokenChainInfoDisplay({ + tokenIconUrl, + chainIconUrl, + resolvedTokenSymbol, + fallbackTokenSymbol, + resolvedChainName, + fallbackChainName, +}: TokenChainInfoDisplayProps) { + const tokenSymbol = resolvedTokenSymbol || fallbackTokenSymbol + const chainName = resolvedChainName || fallbackChainName + + return ( +
+ {(tokenIconUrl || chainIconUrl) && ( +
+ {tokenIconUrl && ( + {`${tokenSymbol} + )} + {chainIconUrl && ( +
+ {`${chainName} +
+ )} +
+ )} + + {tokenSymbol} on {chainName} + +
+ ) +} diff --git a/src/components/Payment/flows/views/RequestPayInitial.tsx b/src/components/Payment/flows/views/RequestPayInitial.tsx new file mode 100644 index 000000000..05857c4b1 --- /dev/null +++ b/src/components/Payment/flows/views/RequestPayInitial.tsx @@ -0,0 +1,376 @@ +'use client' + +import { useState, useContext, useMemo, useEffect } from 'react' +import { Button } from '@/components/0_Bruddle' +import NavHeader from '@/components/Global/NavHeader' +import ErrorAlert from '@/components/Global/ErrorAlert' +import TokenAmountInput from '@/components/Global/TokenAmountInput' +import TokenSelector from '@/components/Global/TokenSelector/TokenSelector' +import UserCard from '@/components/User/UserCard' +import GuestLoginCta from '@/components/Global/GuestLoginCta' +import AddressLink from '@/components/Global/AddressLink' +import ActionModal from '@/components/Global/ActionModal' +import { useAuth } from '@/context/authContext' +import { useWallet } from '@/hooks/wallet/useWallet' +import { useAccount } from 'wagmi' +import { useAppKit, useDisconnect } from '@reown/appkit/react' +import { tokenSelectorContext } from '@/context' +import { formatAmount, areEvmAddressesEqual } from '@/utils' +import { formatUnits } from 'viem' +import { + PEANUT_WALLET_TOKEN_DECIMALS, + PEANUT_WALLET_TOKEN_SYMBOL, + PEANUT_WALLET_CHAIN, + PEANUT_WALLET_TOKEN, +} from '@/constants' +import type { TRequestChargeResponse } from '@/services/services.types' +import type { RequestPayPayload, PaymentRecipient } from '@/hooks/payment/types' +import { useRouter } from 'next/navigation' + +interface RequestPayInitialProps { + recipient?: string[] + chargeDetails?: TRequestChargeResponse | null + requestId?: string | null + onSubmit: (payload: RequestPayPayload) => void + onBack: () => void + error?: string | null + isCreatingCharge?: boolean +} + +/** + * RequestPayInitial - Initial view for request payment flow + * + * This component maintains the exact same UI as the legacy PaymentForm + * but uses modern TanStack Query architecture underneath. + * + * Features: + * - Token selection for external wallets + * - Amount input (pre-filled from request) + * - Wallet connection handling + * - Balance validation + * - Guest user handling + */ +export const RequestPayInitial = ({ + recipient, + chargeDetails, + requestId, + onSubmit, + onBack, + error, + isCreatingCharge = false, +}: RequestPayInitialProps) => { + const router = useRouter() + const { user } = useAuth() + const { isConnected: isPeanutWallet, balance } = useWallet() + const { isConnected: isExternalWallet, address: wagmiAddress } = useAccount() + const { open: openWalletModal } = useAppKit() + const { disconnect: disconnectWagmi } = useDisconnect() + const { + selectedTokenData, + selectedChainID, + selectedTokenAddress, + selectedTokenBalance, + setSelectedChainID, + setSelectedTokenAddress, + setSelectedTokenDecimals, + } = useContext(tokenSelectorContext) + + const [inputTokenAmount, setInputTokenAmount] = useState(chargeDetails?.tokenAmount || '') + const [disconnectWagmiModal, setDisconnectWagmiModal] = useState(false) + const [balanceError, setBalanceError] = useState(null) + const [initialSetupDone, setInitialSetupDone] = useState(false) + + const isConnected = isPeanutWallet || isExternalWallet + const isUsingExternalWallet = !isPeanutWallet + const isActivePeanutWallet = useMemo(() => !!user && isPeanutWallet, [user, isPeanutWallet]) + + // Format Peanut wallet balance for display + const peanutWalletBalance = useMemo(() => { + return formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) + }, [balance]) + + // Initialize token selection from charge details + useEffect(() => { + if (initialSetupDone || !chargeDetails) return + + // Set up token/chain from charge details for external wallets + if (chargeDetails.chainId && !isActivePeanutWallet) { + setSelectedChainID(chargeDetails.chainId.toString()) + } + + if (chargeDetails.tokenAddress && !isActivePeanutWallet) { + setSelectedTokenAddress(chargeDetails.tokenAddress) + // Set decimals if available + if (chargeDetails.tokenDecimals) { + setSelectedTokenDecimals(chargeDetails.tokenDecimals) + } + } + + setInitialSetupDone(true) + }, [ + chargeDetails, + isActivePeanutWallet, + initialSetupDone, + setSelectedChainID, + setSelectedTokenAddress, + setSelectedTokenDecimals, + ]) + + // Balance validation effect + useEffect(() => { + setBalanceError(null) + + const currentInputAmountStr = String(inputTokenAmount) + const parsedInputAmount = parseFloat(currentInputAmountStr.replace(/,/g, '')) + + if (!currentInputAmountStr || isNaN(parsedInputAmount) || parsedInputAmount <= 0) { + return + } + + try { + if (isActivePeanutWallet && areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN)) { + // Peanut wallet payment + const walletNumeric = parseFloat(String(peanutWalletBalance).replace(/,/g, '')) + if (walletNumeric < parsedInputAmount) { + setBalanceError('Insufficient balance') + } + } else if ( + isExternalWallet && + !isActivePeanutWallet && + selectedTokenData && + selectedTokenBalance !== undefined + ) { + // External wallet payment + if (selectedTokenData.decimals === undefined) { + setBalanceError('Cannot verify balance: token data incomplete.') + return + } + const numericSelectedTokenBalance = parseFloat(String(selectedTokenBalance).replace(/,/g, '')) + if (numericSelectedTokenBalance < parsedInputAmount) { + setBalanceError('Insufficient balance') + } + } + } catch (e) { + console.error('Error during balance check:', e) + setBalanceError('Error verifying balance') + } + }, [ + selectedTokenBalance, + peanutWalletBalance, + selectedTokenAddress, + inputTokenAmount, + isActivePeanutWallet, + selectedTokenData, + isExternalWallet, + ]) + + // Check if Peanut wallet USDC is selected + const isPeanutWalletUSDC = useMemo(() => { + return ( + selectedTokenData?.symbol === PEANUT_WALLET_TOKEN_SYMBOL && + Number(selectedChainID) === PEANUT_WALLET_CHAIN.id + ) + }, [selectedTokenData, selectedChainID]) + + // Determine if we can proceed with payment (like CryptoWithdrawFlow) + const canProceed = useMemo(() => { + if (!isConnected) return false + if (!inputTokenAmount || parseFloat(inputTokenAmount) <= 0) return false + if (balanceError) return false + + // For external wallets, need token selection + if (isUsingExternalWallet && (!selectedTokenAddress || !selectedChainID)) return false + + // For Peanut wallet, always need token selection (destination) + if (isActivePeanutWallet && (!selectedTokenAddress || !selectedChainID)) return false + + return true + }, [ + isConnected, + inputTokenAmount, + isUsingExternalWallet, + selectedTokenAddress, + selectedChainID, + balanceError, + isActivePeanutWallet, + ]) + + const handleSubmit = () => { + if (!canProceed) return + + // Convert recipient array to PaymentRecipient format for dynamic charge creation + const recipientData: PaymentRecipient | undefined = + recipient && recipient.length > 0 + ? { + identifier: recipient[0], // First element is the identifier + resolvedAddress: recipient[0], // For now, assume it's an address - will be resolved later + recipientType: 'ADDRESS' as const, // Default to ADDRESS, will be determined during processing + } + : undefined + + const payload: RequestPayPayload = { + // For existing charges/requests + chargeId: chargeDetails?.uuid, + requestId: requestId || undefined, + tokenAmount: inputTokenAmount, + + // For dynamic charge creation (like CryptoWithdrawFlow) + recipient: recipientData, + selectedTokenAddress: selectedTokenAddress || undefined, + selectedChainID: selectedChainID || undefined, + } + + onSubmit(payload) + } + + const handleConnectWallet = () => { + if (!user) { + // Guest user - redirect to setup + router.push('/setup') + return + } + openWalletModal() + } + + // Guest user actions + const guestAction = () => { + if (isConnected || user) return null + return ( +
+ + +
+ ) + } + + const displayError = error || balanceError + + return ( +
+ + +
+ {/* External Wallet Switch Button */} + {isExternalWallet && isUsingExternalWallet && ( + + )} + + {/* Recipient Info Card */} + {chargeDetails && ( + + )} + + {/* Amount Input */} + setInputTokenAmount(value || '')} + className="w-full" + disabled={!!chargeDetails?.tokenAmount} // Disable if amount is pre-set + walletBalance={isActivePeanutWallet ? peanutWalletBalance : undefined} + /> + + {/* Token Selector - Show for all connected users (like CryptoWithdrawFlow) */} + {isConnected && ( +
+ {!isPeanutWalletUSDC && !selectedTokenAddress && !selectedChainID && ( +
+ {isActivePeanutWallet + ? 'Select token and chain to receive' + : 'Select token and chain to send from'} +
+ )} + + {!isPeanutWalletUSDC && selectedTokenAddress && selectedChainID && ( +
+ Use USDC on Arbitrum for free transactions! +
+ )} +
+ )} + + {/* Action Buttons */} +
+ {/* Guest user actions */} + {guestAction()} + + {/* Connected user actions */} + {isConnected && ( + + )} + + {/* Error display */} + {displayError && } +
+
+ + {/* Disconnect Wallet Modal */} + setDisconnectWagmiModal(false)} + title="Disconnect wallet?" + description="You'll need to reconnect to continue using crypto features." + icon="switch" + ctaClassName="flex-row" + hideModalCloseButton={true} + ctas={[ + { + text: 'Disconnect', + onClick: () => { + disconnectWagmi() + setDisconnectWagmiModal(false) + }, + shadowSize: '4', + }, + { + text: 'Cancel', + onClick: () => { + setDisconnectWagmiModal(false) + }, + shadowSize: '4', + className: 'bg-grey-4 hover:bg-grey-4 hover:text-black active:bg-grey-4', + }, + ]} + /> +
+ ) +} diff --git a/src/components/Payment/flows/views/RequestPayStatus.tsx b/src/components/Payment/flows/views/RequestPayStatus.tsx new file mode 100644 index 000000000..44607d51e --- /dev/null +++ b/src/components/Payment/flows/views/RequestPayStatus.tsx @@ -0,0 +1,215 @@ +'use client' + +import { Button } from '@/components/0_Bruddle' +import AddressLink from '@/components/Global/AddressLink' +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import NavHeader from '@/components/Global/NavHeader' +import { TransactionDetailsDrawer } from '@/components/TransactionDetails/TransactionDetailsDrawer' +import { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { useAuth } from '@/context/authContext' +import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' +import { useTokenChainIcons } from '@/hooks/useTokenChainIcons' +import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory' +import { formatAmount, getInitialsFromName } from '@/utils' +import { useRouter } from 'next/navigation' +import { useMemo } from 'react' +import type { TRequestChargeResponse } from '@/services/services.types' +import type { StatusType } from '@/components/Global/Badges/StatusBadge' + +interface RequestPayStatusProps { + success: boolean + transactionHash?: string | null + chargeDetails?: TRequestChargeResponse | null + error?: string | null + onRetry: () => void + onClose: () => void +} + +/** + * RequestPayStatus - Status view for request payment flow + * + * This component matches the exact UI of the legacy DirectSuccessView + * with proper card layout, text hierarchy, and transaction drawer integration. + */ +export const RequestPayStatus = ({ + success, + transactionHash, + chargeDetails, + error, + onRetry, + onClose, +}: RequestPayStatusProps) => { + const router = useRouter() + const { user } = useAuth() + const { openTransactionDetails, closeTransactionDetails, isDrawerOpen, selectedTransaction } = + useTransactionDetailsDrawer() + + // Get recipient information + const recipientName = chargeDetails?.requestLink.recipientAddress || 'Unknown' + const recipientType: 'ADDRESS' | 'USERNAME' | 'ENS' = 'ADDRESS' // Could be enhanced to detect USERNAME/ENS + const displayAmount = chargeDetails ? `$ ${formatAmount(chargeDetails.tokenAmount)}` : '' + const message = chargeDetails?.requestLink.reference + + // Token and chain icons + const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({ + chainId: chargeDetails?.chainId, + tokenAddress: chargeDetails?.tokenAddress, + tokenSymbol: chargeDetails?.tokenSymbol, + }) + + // Transaction details for drawer + const transactionForDrawer = useMemo((): TransactionDetails | null => { + if (!success || !chargeDetails || !transactionHash) return null + + const details: TransactionDetails = { + id: transactionHash, + txHash: transactionHash, + status: 'completed' as StatusType, + amount: parseFloat(chargeDetails.tokenAmount), + date: new Date(), + tokenSymbol: chargeDetails.tokenSymbol, + direction: 'send', + initials: getInitialsFromName(recipientName), + extraDataForDrawer: { + isLinkTransaction: false, + originalType: EHistoryEntryType.DIRECT_SEND, + originalUserRole: EHistoryUserRole.SENDER, + }, + userName: recipientName, + sourceView: 'status', + memo: message || undefined, + tokenDisplayDetails: { + tokenSymbol: resolvedTokenSymbol || chargeDetails.tokenSymbol, + chainName: resolvedChainName, + tokenIconUrl: tokenIconUrl, + chainIconUrl: chainIconUrl, + }, + networkFeeDetails: { + amountDisplay: 'Sponsored by Peanut!', + }, + peanutFeeDetails: { + amountDisplay: '$ 0.00', + }, + } + + return details + }, [ + success, + chargeDetails, + transactionHash, + recipientName, + message, + resolvedTokenSymbol, + resolvedChainName, + tokenIconUrl, + chainIconUrl, + ]) + + const handleDone = () => { + if (user?.user.userId) { + router.push('/home') + } else { + router.push('/setup') + } + onClose() + } + + const handleRetryClick = () => { + onRetry() + } + + // Error state + if (!success) { + return ( +
+ +
+ +
+
+ +
+
+
+

Payment failed

+

Try again

+ {error &&

{error}

} +
+
+ +
+ + +
+
+
+ ) + } + + // Success state - matches original DirectSuccessView exactly + return ( +
+
+ router.push('/home')} /> +
+ +
+ +
+
+ +
+
+
+

+ You sent{' '} + +

+

{displayAmount}

+ {message &&

for {message}

} +
+
+ +
+ {user?.user.userId ? ( + + ) : ( + + )} + + +
+
+ + {/* Transaction Details Drawer */} + +
+ ) +} diff --git a/src/hooks/payment/index.ts b/src/hooks/payment/index.ts new file mode 100644 index 000000000..9f865803b --- /dev/null +++ b/src/hooks/payment/index.ts @@ -0,0 +1,16 @@ +// Payment flow hooks +export { useDirectSendFlow } from './useDirectSendFlow' +export { useAddMoneyFlow } from './useAddMoneyFlow' +export { useCryptoWithdrawFlow } from './useCryptoWithdrawFlow' +export { useRequestPayFlow } from './useRequestPayFlow' + +// Types +export type { + BasePaymentResult, + AttachmentOptions, + PaymentRecipient, + DirectSendPayload, + AddMoneyPayload, + WithdrawPayload, + RequestPayPayload, +} from './types' diff --git a/src/hooks/payment/types.ts b/src/hooks/payment/types.ts new file mode 100644 index 000000000..5ff58b0ef --- /dev/null +++ b/src/hooks/payment/types.ts @@ -0,0 +1,56 @@ +import type { PaymentCreationResponse, TRequestChargeResponse } from '@/services/services.types' + +// Common types for all payment flows +export interface BasePaymentResult { + success: boolean + charge?: TRequestChargeResponse + payment?: PaymentCreationResponse + txHash?: string + error?: string +} + +export interface AttachmentOptions { + message?: string + rawFile?: File +} + +export interface PaymentRecipient { + identifier: string + resolvedAddress: string + recipientType?: 'ADDRESS' | 'ENS' | 'USERNAME' +} + +// Flow-specific payload types +export interface DirectSendPayload { + recipient: PaymentRecipient + tokenAmount: string + requestId?: string + attachmentOptions?: AttachmentOptions +} + +export interface AddMoneyPayload { + tokenAmount: string + fromChainId: string + fromTokenAddress: string + attachmentOptions?: AttachmentOptions +} + +export interface WithdrawPayload { + recipient: PaymentRecipient + tokenAmount: string + toChainId: string + toTokenAddress: string +} + +export interface RequestPayPayload { + // For existing charges/requests + chargeId?: string + requestId?: string + tokenAmount: string + recipient?: PaymentRecipient + attachmentOptions?: AttachmentOptions + + // For dynamic charge creation (like CryptoWithdrawFlow) + selectedTokenAddress?: string + selectedChainID?: string +} diff --git a/src/hooks/payment/useAddMoneyFlow.ts b/src/hooks/payment/useAddMoneyFlow.ts new file mode 100644 index 000000000..d9890e575 --- /dev/null +++ b/src/hooks/payment/useAddMoneyFlow.ts @@ -0,0 +1,242 @@ +'use client' + +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' +import { chargesApi } from '@/services/charges' +import { getRoute } from '@/services/swap' +import { CreateChargeRequest, PaymentCreationResponse, TRequestChargeResponse } from '@/services/services.types' +import { ErrorHandler, isNativeCurrency } from '@/utils' +import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' +import { useCallback, useState } from 'react' +import { parseUnits } from 'viem' +import type { Address, TransactionReceipt } from 'viem' +import { useConfig, useSendTransaction, useSwitchChain, useAccount } from 'wagmi' +import { waitForTransactionReceipt } from 'wagmi/actions' +import type { AddMoneyPayload, BasePaymentResult } from './types' + +/** + * Hook for handling add money flow (External wallet → Peanut wallet) + * + * This flow handles: + * 1. Create charge for deposit + * 2. Get cross-chain route if needed + * 3. Execute transactions via external wallet + * 4. Create payment record + * + * Supports cross-chain deposits and different tokens. + */ +export const useAddMoneyFlow = () => { + const { sendTransactionAsync } = useSendTransaction() + const { switchChainAsync } = useSwitchChain() + const { address: wagmiAddress, chain: connectedChain } = useAccount() + const config = useConfig() + + const [isProcessing, setIsProcessing] = useState(false) + const [error, setError] = useState(null) + const [chargeDetails, setChargeDetails] = useState(null) + const [paymentDetails, setPaymentDetails] = useState(null) + const [transactionHash, setTransactionHash] = useState(null) + const [isPreparingRoute, setIsPreparingRoute] = useState(false) + const [estimatedFees, setEstimatedFees] = useState(undefined) + + const reset = useCallback(() => { + setError(null) + setChargeDetails(null) + setPaymentDetails(null) + setTransactionHash(null) + setIsPreparingRoute(false) + setEstimatedFees(undefined) + }, []) + + const addMoney = useCallback( + async (payload: AddMoneyPayload): Promise => { + if (!wagmiAddress) { + return { success: false, error: 'External wallet not connected' } + } + + setIsProcessing(true) + setError(null) + + try { + console.log('🚀 Starting add money flow:', payload) + + // 1. Create charge for deposit + const createChargePayload: CreateChargeRequest = { + pricing_type: 'fixed_price', + local_price: { + amount: payload.tokenAmount, + currency: 'USD', + }, + baseUrl: window.location.origin, + requestProps: { + chainId: PEANUT_WALLET_CHAIN.id.toString(), + tokenAmount: payload.tokenAmount, + tokenAddress: PEANUT_WALLET_TOKEN, + tokenType: peanutInterfaces.EPeanutLinkType.erc20, + tokenSymbol: 'USDC', + tokenDecimals: 6, + recipientAddress: wagmiAddress, // User's own Peanut wallet + }, + transactionType: 'DEPOSIT', + } + + // Add attachment if present + if (payload.attachmentOptions?.rawFile) { + createChargePayload.attachment = payload.attachmentOptions.rawFile + createChargePayload.filename = payload.attachmentOptions.rawFile.name + createChargePayload.mimeType = payload.attachmentOptions.rawFile.type + } + if (payload.attachmentOptions?.message) { + createChargePayload.reference = payload.attachmentOptions.message + } + + console.log('📝 Creating charge for add money:', createChargePayload) + const charge = await chargesApi.create(createChargePayload) + + if (!charge.data.id) { + throw new Error('Charge created but UUID is missing') + } + + const fullChargeDetails = await chargesApi.get(charge.data.id) + setChargeDetails(fullChargeDetails) + + // 2. Check if cross-chain route is needed + const isXChain = payload.fromChainId !== PEANUT_WALLET_CHAIN.id.toString() + const isDiffToken = payload.fromTokenAddress.toLowerCase() !== PEANUT_WALLET_TOKEN.toLowerCase() + + let transactions: Array<{ to: Address; data: string; value: bigint }> = [] + let feeCostsUsd = 0 + + if (isXChain || isDiffToken) { + console.log('🔄 Cross-chain/token swap needed, getting route...') + setIsPreparingRoute(true) + + const route = await getRoute({ + from: { + address: wagmiAddress, + tokenAddress: payload.fromTokenAddress as Address, + chainId: payload.fromChainId, + }, + to: { + address: wagmiAddress, + tokenAddress: PEANUT_WALLET_TOKEN as Address, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + }, + toAmount: parseUnits(payload.tokenAmount, 6), // USDC has 6 decimals + }) + + transactions = route.transactions.map((tx) => ({ + to: tx.to, + data: tx.data, + value: BigInt(tx.value), + })) + feeCostsUsd = route.feeCostsUsd + setEstimatedFees(feeCostsUsd) + setIsPreparingRoute(false) + } else { + // Same chain, same token - simple transfer + const transferData = isNativeCurrency(payload.fromTokenAddress) + ? '0x' // Native token transfer + : '0x' // ERC20 transfer (would need proper encoding) + + transactions = [ + { + to: PEANUT_WALLET_TOKEN as Address, + data: transferData, + value: isNativeCurrency(payload.fromTokenAddress) + ? parseUnits(payload.tokenAmount, 18) + : 0n, + }, + ] + } + + // 3. Switch network if needed + const sourceChainId = Number(payload.fromChainId) + if (connectedChain?.id !== sourceChainId) { + console.log(`🔄 Switching network to ${sourceChainId}`) + await switchChainAsync({ chainId: sourceChainId }) + } + + // 4. Execute transactions + console.log(`💸 Executing ${transactions.length} transaction(s)...`) + let finalReceipt: TransactionReceipt | null = null + + for (let i = 0; i < transactions.length; i++) { + const tx = transactions[i] + console.log(`📤 Sending transaction ${i + 1}/${transactions.length}`) + + const hash = await sendTransactionAsync({ + to: tx.to, + data: tx.data as `0x${string}`, + value: tx.value, + chainId: sourceChainId, + }) + + const receipt = await waitForTransactionReceipt(config, { + hash, + chainId: sourceChainId, + confirmations: 1, + }) + + finalReceipt = receipt + console.log(`✅ Transaction ${i + 1} confirmed:`, hash) + } + + if (!finalReceipt?.transactionHash) { + throw new Error('Transaction failed or receipt missing') + } + + setTransactionHash(finalReceipt.transactionHash) + + // 5. Create payment record + console.log('📊 Creating payment record...') + const payment = await chargesApi.createPayment({ + chargeId: fullChargeDetails.uuid, + chainId: payload.fromChainId, + hash: finalReceipt.transactionHash, + tokenAddress: payload.fromTokenAddress, + payerAddress: wagmiAddress, + }) + + setPaymentDetails(payment) + console.log('🎉 Add money flow completed successfully!') + + return { + success: true, + charge: fullChargeDetails, + payment, + txHash: finalReceipt.transactionHash, + } + } catch (err) { + console.error('❌ Add money flow failed:', err) + const errorMessage = ErrorHandler(err) + setError(errorMessage) + + return { + success: false, + error: errorMessage, + } + } finally { + setIsProcessing(false) + setIsPreparingRoute(false) + } + }, + [wagmiAddress, sendTransactionAsync, switchChainAsync, connectedChain, config] + ) + + return { + // Main action + addMoney, + + // State + isProcessing, + isPreparingRoute, + error, + chargeDetails, + paymentDetails, + transactionHash, + estimatedFees, + + // Utilities + reset, + } +} diff --git a/src/hooks/payment/useCryptoWithdrawFlow.ts b/src/hooks/payment/useCryptoWithdrawFlow.ts new file mode 100644 index 000000000..35594f136 --- /dev/null +++ b/src/hooks/payment/useCryptoWithdrawFlow.ts @@ -0,0 +1,356 @@ +'use client' + +import { + PEANUT_WALLET_CHAIN, + PEANUT_WALLET_TOKEN, + ROUTE_NOT_FOUND_ERROR, + PEANUT_WALLET_TOKEN_DECIMALS, +} from '@/constants' +import { useWallet } from '@/hooks/wallet/useWallet' +import { chargesApi } from '@/services/charges' +import { getRoute, type PeanutCrossChainRoute } from '@/services/swap' +import { CreateChargeRequest, PaymentCreationResponse, TRequestChargeResponse } from '@/services/services.types' +import { ErrorHandler, NATIVE_TOKEN_ADDRESS, getTokenDetails, isStableCoin, formatAmount } from '@/utils' +import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' +import { useCallback, useState, useMemo } from 'react' +import type { Address, TransactionReceipt } from 'viem' +import { formatUnits, parseUnits } from 'viem' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import type { WithdrawPayload, BasePaymentResult } from './types' + +/** + * Route Query Hook - Uses TanStack Query for route fetching + * + * Handles: + * - Automatic request deduplication + * - Request cancellation on new queries + * - Stale data management + * - Built-in retry logic + * - Cache invalidation for route refresh + */ +const useRouteQuery = (payload: WithdrawPayload | null, peanutWalletAddress: string | null) => { + return useQuery({ + queryKey: ['crypto-withdraw-route', payload], + queryFn: async (): Promise => { + if (!payload || !peanutWalletAddress) return null + + const isXChain = PEANUT_WALLET_CHAIN.id.toString() !== payload.toChainId + const isDiffToken = PEANUT_WALLET_TOKEN.toLowerCase() !== payload.toTokenAddress.toLowerCase() + + if (!isXChain && !isDiffToken) { + return null // No route needed for same chain/token + } + + console.log('Fetching cross-chain route for withdrawal...') + + const route = await getRoute({ + from: { + address: peanutWalletAddress as Address, + tokenAddress: PEANUT_WALLET_TOKEN as Address, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + }, + to: { + address: payload.recipient.resolvedAddress as Address, + tokenAddress: payload.toTokenAddress as Address, + chainId: payload.toChainId, + }, + fromAmount: parseUnits(payload.tokenAmount, PEANUT_WALLET_TOKEN_DECIMALS), + }) + + // RFQ validation for Peanut wallet flows + if (route.type === 'swap') { + console.warn('No RFQ route found for this token pair, only swap route available') + throw new Error(ROUTE_NOT_FOUND_ERROR) + } + + console.log('Cross-chain route fetched successfully:', { + expiry: route.expiry, + type: route.type, + feeCostsUsd: route.feeCostsUsd, + }) + + return route + }, + enabled: !!payload && !!peanutWalletAddress, + staleTime: 30000, // Routes are fresh for 30 seconds + gcTime: 60000, // Keep in cache for 1 minute + retry: (failureCount, error) => { + // Don't retry RFQ validation errors + if (error instanceof Error && error.message === ROUTE_NOT_FOUND_ERROR) { + return false + } + return failureCount < 2 + }, + refetchOnWindowFocus: false, // Don't refetch on window focus + refetchOnReconnect: true, // Do refetch on network reconnect + }) +} + +/** + * Enhanced Crypto Withdraw Flow Hook with TanStack Query + * + * This flow handles: + * 1. Route preparation with TanStack Query (automatic caching, deduplication, cancellation) + * 2. Create charge for the request + * 3. Execute transactions via Peanut wallet + * 4. Create payment record + * + * Features: + * - Automatic route caching and deduplication + * - Request cancellation prevents race conditions + * - Stale data management with refresh capabilities + * - Built-in retry logic for network failures + * - Min received calculation from actual route data + * - RFQ validation for Peanut wallet flows + */ +export const useCryptoWithdrawFlow = () => { + const { sendTransactions, sendMoney, address: peanutWalletAddress } = useWallet() + const queryClient = useQueryClient() + + // Core processing state (non-route related) + const [isProcessing, setIsProcessing] = useState(false) + const [error, setError] = useState(null) + const [chargeDetails, setChargeDetails] = useState(null) + const [paymentDetails, setPaymentDetails] = useState(null) + const [transactionHash, setTransactionHash] = useState(null) + + // Route preparation state - managed by TanStack Query + const [currentPayload, setCurrentPayload] = useState(null) + + // Route query - handles all route fetching logic + const { + data: xChainRoute, + isLoading: isPreparingRoute, + error: routeQueryError, + refetch: refetchRoute, + isStale, + isFetching, + } = useRouteQuery(currentPayload, peanutWalletAddress) + + // Computed values + const isCrossChain = !!xChainRoute + const estimatedFees = xChainRoute?.feeCostsUsd || 0 + const routeExpiry = xChainRoute?.expiry + const isRouteExpired = routeExpiry ? new Date(routeExpiry).getTime() < Date.now() : false + const routeError = routeQueryError?.message || null + + // Min received calculation from actual route data + const minReceived = useMemo(() => { + if (!xChainRoute || !currentPayload) return null + + const tokenDetails = getTokenDetails({ + tokenAddress: currentPayload.toTokenAddress as Address, + chainId: currentPayload.toChainId, + }) + + if (!tokenDetails) return null + + const minReceivedAmount = formatUnits( + BigInt(xChainRoute.rawResponse.route.estimate.toAmountMin), + tokenDetails.decimals + ) + + return isStableCoin(tokenDetails.symbol) + ? `$ ${formatAmount(minReceivedAmount)}` + : `${formatAmount(minReceivedAmount)} ${tokenDetails.symbol}` + }, [xChainRoute, currentPayload]) + + // Route preparation - just sets payload, TanStack Query handles the rest + const prepareRoute = useCallback((payload: WithdrawPayload) => { + console.log('Preparing route for withdrawal...', payload) + setCurrentPayload(payload) + setError(null) // Clear any previous errors + return Promise.resolve(true) // Always succeeds immediately, query handles async + }, []) + + // Route refresh - invalidate cache and refetch + const refreshRoute = useCallback(async () => { + if (!currentPayload) return + + console.log('Refreshing route due to expiry...') + + // Invalidate the query cache + await queryClient.invalidateQueries({ + queryKey: ['crypto-withdraw-route', currentPayload], + }) + + // Trigger immediate refetch + await refetchRoute() + }, [currentPayload, queryClient, refetchRoute]) + + // Withdraw method - uses cached route data + const withdraw = useCallback(async (): Promise => { + if (!peanutWalletAddress || !currentPayload) { + return { success: false, error: 'Missing required data for withdrawal' } + } + + // Validate route if cross-chain + if (isCrossChain) { + if (!xChainRoute) { + return { success: false, error: 'Cross-chain route not prepared' } + } + + if (isRouteExpired) { + return { success: false, error: 'Route has expired. Please refresh and try again.' } + } + } + + setIsProcessing(true) + setError(null) + + try { + console.log('Starting withdrawal process with payload:', currentPayload) + + // Get token details for the target token + const tokenDetails = getTokenDetails({ + tokenAddress: currentPayload.toTokenAddress as Address, + chainId: currentPayload.toChainId, + }) + + if (!tokenDetails) { + throw new Error('Unable to get token details for withdrawal') + } + + const tokenType = + currentPayload.toTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase() + ? peanutInterfaces.EPeanutLinkType.native + : peanutInterfaces.EPeanutLinkType.erc20 + + // Create charge + const chargePayload: CreateChargeRequest = { + pricing_type: 'fixed_price', + local_price: { amount: currentPayload.tokenAmount, currency: 'USD' }, + baseUrl: window.location.origin, + requestProps: { + chainId: currentPayload.toChainId, + tokenAmount: currentPayload.tokenAmount, + tokenAddress: currentPayload.toTokenAddress, + tokenType: tokenType, + tokenSymbol: tokenDetails.symbol, + tokenDecimals: tokenDetails.decimals, + recipientAddress: currentPayload.recipient.resolvedAddress, + }, + transactionType: 'WITHDRAW', + } + + console.log('Creating charge with payload:', chargePayload) + const charge = await chargesApi.create(chargePayload) + + if (!charge || !charge.data || !charge.data.id) { + throw new Error('Failed to create charge for withdrawal or charge ID missing.') + } + + const fullChargeDetails = await chargesApi.get(charge.data.id) + setChargeDetails(fullChargeDetails) + console.log('Charge created successfully:', fullChargeDetails.uuid) + + // Execute transaction + let receipt: TransactionReceipt + + if (xChainRoute) { + console.log('Executing cross-chain withdrawal with cached route') + // Use cached route transactions + const transactions = xChainRoute.transactions.map((tx) => ({ + to: tx.to, + data: tx.data, + value: BigInt(tx.value), + })) + receipt = await sendTransactions(transactions) + } else { + console.log('Executing same-chain withdrawal') + // Same chain/token - direct send + receipt = await sendMoney( + currentPayload.recipient.resolvedAddress as Address, + currentPayload.tokenAmount + ) + } + + if (!receipt || !receipt.transactionHash) { + throw new Error('Withdrawal transaction failed or receipt missing') + } + + console.log('Transaction successful:', receipt.transactionHash) + setTransactionHash(receipt.transactionHash) + + // Create payment record + const payment = await chargesApi.createPayment({ + chargeId: fullChargeDetails.uuid, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + hash: receipt.transactionHash, + tokenAddress: PEANUT_WALLET_TOKEN, + payerAddress: peanutWalletAddress, + }) + + setPaymentDetails(payment) + console.log('Payment record created successfully') + + return { + success: true, + charge: fullChargeDetails, + payment, + txHash: receipt.transactionHash, + } + } catch (err) { + console.error('Withdrawal failed:', err) + const errorMessage = ErrorHandler(err) + setError(errorMessage) + + return { + success: false, + error: errorMessage, + } + } finally { + setIsProcessing(false) + } + }, [peanutWalletAddress, currentPayload, xChainRoute, isCrossChain, isRouteExpired, sendTransactions, sendMoney]) + + const reset = useCallback(() => { + setCurrentPayload(null) + setError(null) + setChargeDetails(null) + setPaymentDetails(null) + setTransactionHash(null) + + // Clear route cache + queryClient.removeQueries({ + queryKey: ['crypto-withdraw-route'], + }) + }, [queryClient]) + + // Combined error state + const displayError = useMemo(() => error || routeError, [error, routeError]) + + return { + // Main actions + prepareRoute, + refreshRoute, + withdraw, + reset, + + // Core state + isProcessing, + error, + setError, + chargeDetails, + paymentDetails, + transactionHash, + + // Route state (from TanStack Query) + isPreparingRoute, + xChainRoute, + routeError, + isRouteExpired, + routeExpiry, + isStale, // Indicates if route data might be outdated + isFetching, // Indicates if query is currently fetching + + // Computed values + isCrossChain, + estimatedFees, + minReceived, + displayError, + + // Current payload for debugging and consistency + currentPayload, + } +} diff --git a/src/hooks/payment/useDirectSendFlow.ts b/src/hooks/payment/useDirectSendFlow.ts new file mode 100644 index 000000000..8c1b67011 --- /dev/null +++ b/src/hooks/payment/useDirectSendFlow.ts @@ -0,0 +1,172 @@ +'use client' + +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' +import { useWallet } from '@/hooks/wallet/useWallet' +import { chargesApi } from '@/services/charges' +import { CreateChargeRequest, PaymentCreationResponse, TRequestChargeResponse } from '@/services/services.types' +import { ErrorHandler } from '@/utils' +import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' +import { useCallback, useState } from 'react' +import type { Address } from 'viem' + +export interface DirectSendPayload { + recipient: { + identifier: string + resolvedAddress: string + } + tokenAmount: string + requestId?: string + attachmentOptions?: { + message?: string + rawFile?: File + } +} + +interface DirectSendResult { + success: boolean + charge?: TRequestChargeResponse + payment?: PaymentCreationResponse + txHash?: string + error?: string +} + +/** + * Hook for handling direct send payments (Peanut → Peanut, USDC only) + * + * This is the simplest payment flow: + * 1. Create charge + * 2. Send USDC using Peanut wallet + * 3. Create payment record + * + * No cross-chain complexity, no external wallets, just pure USDC transfers. + */ +export const useDirectSendFlow = () => { + const { sendMoney, address: peanutWalletAddress } = useWallet() + const [isProcessing, setIsProcessing] = useState(false) + const [error, setError] = useState(null) + const [chargeDetails, setChargeDetails] = useState(null) + const [paymentDetails, setPaymentDetails] = useState(null) + const [transactionHash, setTransactionHash] = useState(null) + + const reset = useCallback(() => { + setError(null) + setChargeDetails(null) + setPaymentDetails(null) + setTransactionHash(null) + }, []) + + const sendDirectly = useCallback( + async (payload: DirectSendPayload): Promise => { + setIsProcessing(true) + setError(null) + + try { + console.log('🚀 Starting direct send flow:', payload) + + // 1. Create charge using existing chargesApi + const createChargePayload: CreateChargeRequest = { + pricing_type: 'fixed_price', + local_price: { + amount: payload.tokenAmount, + currency: 'USD', + }, + baseUrl: window.location.origin, + requestId: payload.requestId, + requestProps: { + chainId: PEANUT_WALLET_CHAIN.id.toString(), + tokenAmount: payload.tokenAmount, + tokenAddress: PEANUT_WALLET_TOKEN, + tokenType: peanutInterfaces.EPeanutLinkType.erc20, + tokenSymbol: 'USDC', + tokenDecimals: 6, + recipientAddress: payload.recipient.resolvedAddress, + }, + transactionType: 'DIRECT_SEND', + } + + // Add attachment if present + if (payload.attachmentOptions?.rawFile) { + createChargePayload.attachment = payload.attachmentOptions.rawFile + createChargePayload.filename = payload.attachmentOptions.rawFile.name + createChargePayload.mimeType = payload.attachmentOptions.rawFile.type + } + if (payload.attachmentOptions?.message) { + createChargePayload.reference = payload.attachmentOptions.message + } + + console.log('📝 Creating charge with payload:', createChargePayload) + const charge = await chargesApi.create(createChargePayload) + + if (!charge.data.id) { + throw new Error('Charge created but UUID is missing') + } + + // 2. Get full charge details + console.log('📋 Fetching charge details for ID:', charge.data.id) + const fullChargeDetails = await chargesApi.get(charge.data.id) + setChargeDetails(fullChargeDetails) + + // 3. Send USDC using Peanut wallet (simple transfer) + console.log('💸 Sending USDC to:', fullChargeDetails.requestLink.recipientAddress) + const receipt = await sendMoney( + fullChargeDetails.requestLink.recipientAddress as Address, + payload.tokenAmount + ) + + if (!receipt || !receipt.transactionHash) { + throw new Error('Transaction failed or receipt missing') + } + + setTransactionHash(receipt.transactionHash) + console.log('✅ Transaction successful, hash:', receipt.transactionHash) + + // 4. Create payment record + console.log('📊 Creating payment record...') + const payment = await chargesApi.createPayment({ + chargeId: fullChargeDetails.uuid, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + hash: receipt.transactionHash, + tokenAddress: PEANUT_WALLET_TOKEN, + payerAddress: peanutWalletAddress ?? '', + }) + + setPaymentDetails(payment) + console.log('🎉 Direct send flow completed successfully!') + + return { + success: true, + charge: fullChargeDetails, + payment, + txHash: receipt.transactionHash, + } + } catch (err) { + console.error('❌ Direct send flow failed:', err) + const errorMessage = ErrorHandler(err) + setError(errorMessage) + + return { + success: false, + error: errorMessage, + } + } finally { + setIsProcessing(false) + } + }, + [sendMoney, peanutWalletAddress] + ) + + return { + // Main action + sendDirectly, + + // State + isProcessing, + error, + chargeDetails, + paymentDetails, + transactionHash, + + // Utilities + reset, + } +} diff --git a/src/hooks/payment/useRequestPayFlow.ts b/src/hooks/payment/useRequestPayFlow.ts new file mode 100644 index 000000000..a97f7facd --- /dev/null +++ b/src/hooks/payment/useRequestPayFlow.ts @@ -0,0 +1,348 @@ +'use client' + +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' +import { useWallet } from '@/hooks/wallet/useWallet' +import { chargesApi } from '@/services/charges' +import { getRoute } from '@/services/swap' +import { TRequestChargeResponse, CreateChargeRequest } from '@/services/services.types' +import { ErrorHandler, areEvmAddressesEqual, getTokenDetails, NATIVE_TOKEN_ADDRESS } from '@/utils' +import { useCallback, useState, useContext } from 'react' +import { parseUnits } from 'viem' +import type { Address, TransactionReceipt } from 'viem' +import { useConfig, useSendTransaction, useSwitchChain, useAccount } from 'wagmi' +import { waitForTransactionReceipt } from 'wagmi/actions' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { tokenSelectorContext } from '@/context' +import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' +import type { RequestPayPayload, BasePaymentResult } from './types' + +/** + * Hook for handling request payment flow (fulfilling payment requests) + * + * Modernized with TanStack Query for: + * - Automatic caching and deduplication + * - Built-in retry logic and error handling + * - Race condition elimination + * - Better loading states and UX + * + * This flow handles: + * 1. Get/create charge for request + * 2. Determine wallet type (Peanut vs External) + * 3. Get cross-chain route if needed + * 4. Execute transactions via appropriate wallet + * 5. Create payment record + * + * Supports both Peanut wallet and external wallet payments, with cross-chain capability. + */ +export const useRequestPayFlow = (chargeId?: string) => { + const { sendMoney, sendTransactions, address: peanutWalletAddress, isConnected: isPeanutWallet } = useWallet() + const { sendTransactionAsync } = useSendTransaction() + const { switchChainAsync } = useSwitchChain() + const { address: wagmiAddress, chain: connectedChain } = useAccount() + const config = useConfig() + const queryClient = useQueryClient() + + // Get selected token context for external wallets + const { selectedTokenAddress, selectedChainID } = useContext(tokenSelectorContext) + + // Local state for transaction execution + const [transactionHash, setTransactionHash] = useState(null) + + // TanStack Query for charge details fetching + const chargeQuery = useQuery({ + queryKey: ['charge-details', chargeId], + queryFn: () => chargesApi.get(chargeId!), + enabled: !!chargeId, + staleTime: 30000, // 30 seconds + retry: 3, + }) + + // TanStack Query for route preparation + const routeQuery = useQuery({ + queryKey: [ + 'request-pay-route', + chargeQuery.data, + isPeanutWallet, + connectedChain?.id, + selectedTokenAddress, + selectedChainID, + ], + queryFn: async () => { + const charge = chargeQuery.data! + const activeWalletAddress = isPeanutWallet ? peanutWalletAddress : wagmiAddress + + if (!activeWalletAddress) { + throw new Error('No wallet connected') + } + + const targetChainId = charge.chainId + const targetTokenAddress = charge.tokenAddress + + // For Peanut wallet, we're always sending from PEANUT_WALLET_CHAIN/PEANUT_WALLET_TOKEN + // For external wallets, use selected token/chain from TokenSelector + const sourceChainId = isPeanutWallet + ? PEANUT_WALLET_CHAIN.id.toString() + : selectedChainID || connectedChain?.id.toString() || '1' + + const sourceTokenAddress = isPeanutWallet ? PEANUT_WALLET_TOKEN : selectedTokenAddress || targetTokenAddress + + const isXChain = sourceChainId !== targetChainId + const isDiffToken = !areEvmAddressesEqual(sourceTokenAddress, targetTokenAddress) + + // Only get route if cross-chain/cross-token is needed + if (!isXChain && !isDiffToken) { + return null + } + + + + return await getRoute({ + from: { + address: activeWalletAddress as Address, + tokenAddress: sourceTokenAddress as Address, + chainId: sourceChainId, + }, + to: { + address: charge.requestLink.recipientAddress as Address, + tokenAddress: targetTokenAddress as Address, + chainId: targetChainId, + }, + toAmount: parseUnits(charge.tokenAmount, charge.tokenDecimals), + }) + }, + enabled: + !!chargeQuery.data && + (!!peanutWalletAddress || !!wagmiAddress) && + // For external wallets, wait for token selection + (isPeanutWallet || (!!selectedTokenAddress && !!selectedChainID)), + staleTime: 60000, // 1 minute + retry: 2, + }) + + const reset = useCallback(() => { + setTransactionHash(null) + queryClient.removeQueries({ queryKey: ['request-pay-route'] }) + }, [queryClient]) + + // Separate charge creation function for dynamic scenarios + const createCharge = useCallback( + async (payload: RequestPayPayload): Promise => { + if (!payload.recipient || !payload.selectedTokenAddress || !payload.selectedChainID) { + throw new Error('Missing required data for charge creation') + } + + // Get token details for the target token + const tokenDetails = getTokenDetails({ + tokenAddress: payload.selectedTokenAddress as Address, + chainId: payload.selectedChainID, + }) + + if (!tokenDetails) { + throw new Error('Unable to get token details for payment') + } + + const tokenType = + payload.selectedTokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase() + ? peanutInterfaces.EPeanutLinkType.native + : peanutInterfaces.EPeanutLinkType.erc20 + + // Create charge for dynamic payment + const chargePayload: CreateChargeRequest = { + pricing_type: 'fixed_price', + local_price: { amount: payload.tokenAmount, currency: 'USD' }, + baseUrl: window.location.origin, + requestProps: { + chainId: payload.selectedChainID, + tokenAmount: payload.tokenAmount, + tokenAddress: payload.selectedTokenAddress, + tokenType: tokenType, + tokenSymbol: tokenDetails.symbol, + tokenDecimals: tokenDetails.decimals, + recipientAddress: payload.recipient.resolvedAddress, + }, + transactionType: 'REQUEST', + } + + const charge = await chargesApi.create(chargePayload) + + if (!charge || !charge.data || !charge.data.id) { + throw new Error('Failed to create charge for payment or charge ID missing.') + } + + const fullChargeDetails = await chargesApi.get(charge.data.id) + + // Update the query cache with the new charge + queryClient.setQueryData(['charge-details', fullChargeDetails.uuid], fullChargeDetails) + + return fullChargeDetails + }, + [queryClient] + ) + + // TanStack Query mutation for payment execution + const paymentMutation = useMutation({ + mutationFn: async (payload: RequestPayPayload): Promise => { + const activeWalletAddress = isPeanutWallet ? peanutWalletAddress : wagmiAddress + + if (!activeWalletAddress) { + throw new Error('No wallet connected') + } + + // 1. Get charge details (should exist by now) + let fullChargeDetails: TRequestChargeResponse + + if (payload.chargeId) { + // Get charge from cache or API + fullChargeDetails = + queryClient.getQueryData(['charge-details', payload.chargeId]) || + (await chargesApi.get(payload.chargeId)) + } else { + throw new Error( + 'Request payment requires a charge ID - charge should be created before payment execution' + ) + } + + // 2. Get route from cache or prepare new one + const route = routeQuery.data + + // 3. Determine transaction parameters + const targetChainId = fullChargeDetails.chainId + const targetTokenAddress = fullChargeDetails.tokenAddress + const sourceChainId = isPeanutWallet + ? PEANUT_WALLET_CHAIN.id.toString() + : selectedChainID || connectedChain?.id.toString() || '1' + const sourceTokenAddress = isPeanutWallet ? PEANUT_WALLET_TOKEN : selectedTokenAddress || targetTokenAddress + + const isXChain = sourceChainId !== targetChainId + const isDiffToken = !areEvmAddressesEqual(sourceTokenAddress, targetTokenAddress) + + let receipt: TransactionReceipt + + // 4. Execute transactions based on route type + if (route && (isXChain || isDiffToken)) { + + const transactions = route.transactions.map((tx) => ({ + to: tx.to, + data: tx.data, + value: BigInt(tx.value), + })) + + if (isPeanutWallet) { + receipt = await sendTransactions( + transactions.map((tx) => ({ to: tx.to, data: tx.data, value: tx.value })), + sourceChainId + ) + } else { + // Switch network if needed + if (connectedChain?.id !== Number(sourceChainId)) { + await switchChainAsync({ chainId: Number(sourceChainId) }) + } + + // Execute transactions sequentially + let finalReceipt: TransactionReceipt | null = null + for (let i = 0; i < transactions.length; i++) { + const tx = transactions[i] + const hash = await sendTransactionAsync({ + to: tx.to, + data: tx.data as `0x${string}`, + value: tx.value, + chainId: Number(sourceChainId), + }) + + finalReceipt = await waitForTransactionReceipt(config, { + hash, + chainId: Number(sourceChainId), + confirmations: 1, + }) + } + receipt = finalReceipt! + } + } else { + // Same-chain, same-token payment + if (isPeanutWallet && areEvmAddressesEqual(sourceTokenAddress, PEANUT_WALLET_TOKEN)) { + receipt = await sendMoney( + fullChargeDetails.requestLink.recipientAddress as Address, + fullChargeDetails.tokenAmount + ) + } else { + throw new Error('Same-chain external wallet payments not yet implemented') + } + } + + if (!receipt || !receipt.transactionHash) { + throw new Error('Payment transaction failed or receipt missing') + } + + setTransactionHash(receipt.transactionHash) + + // 5. Create payment record + const payment = await chargesApi.createPayment({ + chargeId: fullChargeDetails.uuid, + chainId: sourceChainId, + hash: receipt.transactionHash, + tokenAddress: sourceTokenAddress, + payerAddress: activeWalletAddress, + }) + + return { + success: true, + charge: fullChargeDetails, + payment, + txHash: receipt.transactionHash, + } + }, + onError: (error) => { + console.error('❌ Request payment flow failed:', error) + }, + }) + + const payRequest = useCallback( + async (payload: RequestPayPayload): Promise => { + try { + return await paymentMutation.mutateAsync(payload) + } catch (error) { + const errorMessage = ErrorHandler(error) + return { + success: false, + error: errorMessage, + } + } + }, + [paymentMutation] + ) + + return { + // Main actions + payRequest, + createCharge, + + // TanStack Query states + isProcessing: paymentMutation.isPending, + isPreparingRoute: routeQuery.isFetching, + error: paymentMutation.error + ? ErrorHandler(paymentMutation.error) + : chargeQuery.error + ? ErrorHandler(chargeQuery.error) + : routeQuery.error + ? ErrorHandler(routeQuery.error) + : null, + + // Data from queries + chargeDetails: chargeQuery.data, + route: routeQuery.data, + transactionHash, + estimatedFees: routeQuery.data?.feeCostsUsd, + + // Loading states + isLoadingCharge: chargeQuery.isLoading, + isLoadingRoute: routeQuery.isLoading, + + // Query states for advanced usage + chargeQuery, + routeQuery, + paymentMutation, + + // Utilities + reset, + } +}