From 6b54db1a4e6903eb4b323033bd1880c028820277 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 10:58:36 -0800 Subject: [PATCH] add buy components --- src/buy/components/Buy.tsx | 75 ++++ src/buy/components/BuyAmountInput.tsx | 44 +++ src/buy/components/BuyButton.tsx | 67 ++++ src/buy/components/BuyDropdown.tsx | 95 +++++ src/buy/components/BuyMessage.tsx | 18 + src/buy/components/BuyOnrampItem.tsx | 55 +++ src/buy/components/BuyProvider.tsx | 479 ++++++++++++++++++++++++++ src/buy/components/BuyTokenItem.tsx | 58 ++++ src/buy/constants.ts | 20 ++ src/buy/hooks/usePopupMonitor.ts | 37 ++ src/buy/index.ts | 2 + src/buy/types.ts | 62 ++++ src/buy/utils/getBuyQuote.ts | 71 ++++ src/swap/index.ts | 1 - 14 files changed, 1083 insertions(+), 1 deletion(-) create mode 100644 src/buy/components/Buy.tsx create mode 100644 src/buy/components/BuyAmountInput.tsx create mode 100644 src/buy/components/BuyButton.tsx create mode 100644 src/buy/components/BuyDropdown.tsx create mode 100644 src/buy/components/BuyMessage.tsx create mode 100644 src/buy/components/BuyOnrampItem.tsx create mode 100644 src/buy/components/BuyProvider.tsx create mode 100644 src/buy/components/BuyTokenItem.tsx create mode 100644 src/buy/constants.ts create mode 100644 src/buy/hooks/usePopupMonitor.ts create mode 100644 src/buy/index.ts create mode 100644 src/buy/types.ts create mode 100644 src/buy/utils/getBuyQuote.ts diff --git a/src/buy/components/Buy.tsx b/src/buy/components/Buy.tsx new file mode 100644 index 0000000000..6c51c48aeb --- /dev/null +++ b/src/buy/components/Buy.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react'; +import { cn } from '../../styles/theme'; +import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; +import type { BuyReact } from '../types'; +import { BuyAmountInput } from './BuyAmountInput'; +import { BuyButton } from './BuyButton'; +import { BuyDropdown } from './BuyDropdown'; +import { BuyMessage } from './BuyMessage'; +import { BuyProvider, useBuyContext } from './BuyProvider'; + +export function BuyContent({ className }: { className?: string }) { + const { isDropdownOpen, setIsDropdownOpen } = useBuyContext(); + const fundSwapContainerRef = useRef(null); + + // Handle clicking outside the wallet component to close the dropdown. + useEffect(() => { + const handleClickOutsideComponent = (event: MouseEvent) => { + if ( + fundSwapContainerRef.current && + !fundSwapContainerRef.current.contains(event.target as Node) && + isDropdownOpen + ) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener('click', handleClickOutsideComponent); + return () => + document.removeEventListener('click', handleClickOutsideComponent); + }, [isDropdownOpen, setIsDropdownOpen]); + + return ( +
+
+ + + {isDropdownOpen && } +
+ +
+ ); +} +export function Buy({ + config = { + maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE, + }, + className, + experimental = { useAggregator: false }, + isSponsored = false, + onError, + onStatus, + onSuccess, + toToken, + fromToken, + projectId, +}: BuyReact) { + return ( + + + + ); +} diff --git a/src/buy/components/BuyAmountInput.tsx b/src/buy/components/BuyAmountInput.tsx new file mode 100644 index 0000000000..7a62d2862b --- /dev/null +++ b/src/buy/components/BuyAmountInput.tsx @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; +import { isValidAmount } from '../../core/utils/isValidAmount'; +import { TextInput } from '../../internal/components/TextInput'; +import { cn, pressable } from '../../styles/theme'; +import { TokenChip } from '../../token'; +import { formatAmount } from '../../swap/utils/formatAmount'; +import { useBuyContext } from './BuyProvider'; + +export function BuyAmountInput() { + const { to, handleAmountChange } = useBuyContext(); + + const handleChange = useCallback( + (amount: string) => { + handleAmountChange(amount); + }, + [handleAmountChange], + ); + + if (!to?.token) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/src/buy/components/BuyButton.tsx b/src/buy/components/BuyButton.tsx new file mode 100644 index 0000000000..24316c0c20 --- /dev/null +++ b/src/buy/components/BuyButton.tsx @@ -0,0 +1,67 @@ +import { useCallback, useMemo } from 'react'; +import { Spinner } from '../../internal/components/Spinner'; +import { checkmarkSvg } from '../../internal/svg/checkmarkSvg'; +import { + background, + border, + cn, + color, + pressable, + text, +} from '../../styles/theme'; +import { useBuyContext } from './BuyProvider'; + +export function BuyButton() { + const { + setIsDropdownOpen, + from, + fromETH, + fromUSDC, + to, + lifecycleStatus: { statusName }, + } = useBuyContext(); + const isLoading = + to?.loading || + from?.loading || + fromETH.loading || + fromUSDC.loading || + statusName === 'transactionPending' || + statusName === 'transactionApproved'; + + const isDisabled = !to?.amount || !to?.token || isLoading; + + const handleSubmit = useCallback(() => { + setIsDropdownOpen(true); + }, [setIsDropdownOpen]); + + const buttonContent = useMemo(() => { + if (statusName === 'success') { + return checkmarkSvg; + } + return 'Buy'; + }, [statusName]); + + return ( + + ); +} diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx new file mode 100644 index 0000000000..c6a14793dd --- /dev/null +++ b/src/buy/components/BuyDropdown.tsx @@ -0,0 +1,95 @@ +import { useCallback, useMemo } from 'react'; +import { useAccount } from 'wagmi'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; +import { ONRAMP_BUY_URL } from '../../fund/constants'; +import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize'; +import { openPopup } from '../../internal/utils/openPopup'; +import { background, cn, color, text } from '../../styles/theme'; +import { ONRAMP_PAYMENT_METHODS } from '../constants'; +import { BuyOnrampItem } from './BuyOnrampItem'; +import { useBuyContext } from './BuyProvider'; +import { BuyTokenItem } from './BuyTokenItem'; + +export function BuyDropdown() { + const { to, fromETH, fromUSDC, from, projectId, startPopupMonitor } = + useBuyContext(); + const { address } = useAccount(); + + const handleOnrampClick = useCallback( + (paymentMethodId: string) => { + return () => { + const assetSymbol = to?.token?.symbol; + let fundAmount = to?.amount; + // funding url requires a leading zero if the amount is less than 1 + if (fundAmount?.[0] === '.') { + fundAmount = `0${fundAmount}`; + } + const fundingUrl = `${ONRAMP_BUY_URL}/one-click?appId=${projectId}&addresses={"${address}":["base"]}&assets=["${assetSymbol}"]&presetCryptoAmount=${fundAmount}&defaultPaymentMethod=${paymentMethodId}`; + const { height, width } = getFundingPopupSize('md', fundingUrl); + const popupWindow = openPopup({ + url: fundingUrl, + height, + width, + target: '_blank', + }); + + if (popupWindow) { + // Detects when the popup is closed + // to stop loading state + startPopupMonitor(popupWindow); + } + }; + }, + [address, to, projectId], + ); + + const formattedAmountUSD = useMemo(() => { + if (!to?.amountUSD || to?.amountUSD === '0') { + return null; + } + const roundedAmount = Number(getRoundedAmount(to?.amountUSD, 2)); + return `$${roundedAmount.toFixed(2)}`; + }, [to?.amountUSD]); + + const isToETH = to?.token?.symbol === 'ETH'; + const isToUSDC = to?.token?.symbol === 'USDC'; + const showFromToken = + to?.token?.symbol !== from?.token?.symbol && + from && + from?.token?.symbol !== 'ETH' && + from?.token?.symbol !== 'USDC'; + + return ( +
+
Buy with
+ {!isToETH && } + {!isToUSDC && } + {showFromToken && } + + {ONRAMP_PAYMENT_METHODS.map((method) => { + return ( + + ); + })} + + {!!formattedAmountUSD && ( +
{`${to?.amount} ${to?.token?.name} ≈ ${formattedAmountUSD}`}
+ )} +
+ ); +} diff --git a/src/buy/components/BuyMessage.tsx b/src/buy/components/BuyMessage.tsx new file mode 100644 index 0000000000..8aede245f6 --- /dev/null +++ b/src/buy/components/BuyMessage.tsx @@ -0,0 +1,18 @@ +import { cn, color } from '../../styles/theme'; +import { useBuyContext } from './BuyProvider'; + +export function BuyMessage() { + const { + lifecycleStatus: { statusName }, + } = useBuyContext(); + + if (statusName !== 'error') { + return null; + } + + return ( +
+ Something went wrong. Please try again. +
+ ); +} diff --git a/src/buy/components/BuyOnrampItem.tsx b/src/buy/components/BuyOnrampItem.tsx new file mode 100644 index 0000000000..84a2e3dd72 --- /dev/null +++ b/src/buy/components/BuyOnrampItem.tsx @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import { appleSvg } from '../../internal/svg/appleSvg'; +import { cardSvg } from '../../internal/svg/cardSvg'; +import { coinbaseLogoSvg } from '../../internal/svg/coinbaseLogoSvg'; +import { cn, color } from '../../styles/theme'; +import { useBuyContext } from './BuyProvider'; + +type OnrampItemReact = { + name: string; + description: string; + onClick: () => void; + svg?: React.ReactNode; + icon: string; +}; + +const ONRAMP_ICON_MAP: Record = { + applePay: appleSvg, + coinbasePay: coinbaseLogoSvg, + creditCard: cardSvg, +}; + +export function BuyOnrampItem({ + name, + description, + onClick, + icon, +}: OnrampItemReact) { + const { setIsDropdownOpen } = useBuyContext(); + + const handleClick = useCallback(() => { + setIsDropdownOpen(false); + onClick(); + }, [onClick, setIsDropdownOpen]); + + return ( + + ); +} diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx new file mode 100644 index 0000000000..9b5da24d43 --- /dev/null +++ b/src/buy/components/BuyProvider.tsx @@ -0,0 +1,479 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { base } from 'viem/chains'; +import { useAccount, useConfig, useSendTransaction } from 'wagmi'; +import { useSwitchChain } from 'wagmi'; +import { useSendCalls } from 'wagmi/experimental'; +import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabilitiesSafe'; +import { useValue } from '../../core-react/internal/hooks/useValue'; +import { useOnchainKit } from '../../core-react/useOnchainKit'; +import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; +import { getBuyQuote } from '../utils/getBuyQuote'; +import { setupOnrampEventListeners } from '../../fund'; +import type { EventMetadata, OnrampError } from '../../fund/types'; +import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; +import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError'; +import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; +import { useAwaitCalls } from '../../swap/hooks/useAwaitCalls'; +import { useLifecycleStatus } from '../../swap/hooks/useLifecycleStatus'; +import { useResetSwapLiteInputs } from '../../swap/hooks/useResetSwapLiteInputs'; +import { useSwapLiteTokens } from '../../swap/hooks/useSwapLiteTokens'; +import type { SwapUnit } from '../../swap/types'; +import { isSwapError } from '../../swap/utils/isSwapError'; +import { processSwapTransaction } from '../../swap/utils/processSwapTransaction'; +import { usePopupMonitor } from '../hooks/usePopupMonitor'; +import { BuyContextType, BuyProviderReact } from '../types'; + +const emptyContext = {} as BuyContextType; + +export const BuyContext = createContext(emptyContext); + +export function useBuyContext() { + const context = useContext(BuyContext); + if (context === emptyContext) { + throw new Error('useBuyContext must be used within a Buy component'); + } + return context; +} + +export function BuyProvider({ + children, + config = { + maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE, + }, + experimental, + isSponsored, + onError, + onStatus, + onSuccess, + toToken, + fromToken, + projectId, +}: BuyProviderReact) { + const { + config: { paymaster } = { paymaster: undefined }, + } = useOnchainKit(); + const { address, chainId } = useAccount(); + const { switchChainAsync } = useSwitchChain(); + // Feature flags + const { useAggregator } = experimental; + // Core Hooks + const accountConfig = useConfig(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const walletCapabilities = useCapabilitiesSafe({ + chainId: base.id, + }); // Swap is only available on Base + const [lifecycleStatus, updateLifecycleStatus] = useLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: true, + maxSlippage: config.maxSlippage, + }, + }); // Component lifecycle + + const [transactionHash, setTransactionHash] = useState(''); + const [hasHandledSuccess, setHasHandledSuccess] = useState(false); + const { from, fromETH, fromUSDC, to } = useSwapLiteTokens( + toToken, + fromToken, + address, + ); + const { sendTransactionAsync } = useSendTransaction(); // Sending the transaction (and approval, if applicable) + const { sendCallsAsync } = useSendCalls(); // Atomic Batch transactions (and approval, if applicable) + + // Refreshes balances and inputs post-swap + const resetInputs = useResetSwapLiteInputs({ fromETH, fromUSDC, from, to }); + // For batched transactions, listens to and awaits calls from the Wallet server + const awaitCallsStatus = useAwaitCalls({ + accountConfig, + lifecycleStatus, + updateLifecycleStatus, + }); + + const handleOnrampEvent = useCallback( + (data: EventMetadata) => { + console.log('EVENT HANDLER', { data }); + if (data.eventName === 'transition_view') { + updateLifecycleStatus({ + statusName: 'transactionPending', + }); + } + }, + [updateLifecycleStatus], + ); + + const handleOnrampExit = useCallback((error?: OnrampError) => { + console.log('EXIT HANDLER', { error }); + }, []); + + const handleOnrampSuccess = useCallback(() => { + console.log('SUCCESS HANDLER'); + updateLifecycleStatus({ + statusName: 'success', + statusData: {}, + }); + }, []); + + const onPopupClose = useCallback(() => { + updateLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: false, + maxSlippage: config.maxSlippage, + }, + }); + }, [updateLifecycleStatus]); + + useEffect(() => { + const unsubscribe = setupOnrampEventListeners({ + onEvent: handleOnrampEvent, + onExit: handleOnrampExit, + onSuccess: handleOnrampSuccess, + }); + return () => { + unsubscribe(); + }; + }, [handleOnrampEvent, handleOnrampExit, handleOnrampSuccess]); + + const { startPopupMonitor } = usePopupMonitor(onPopupClose); + + // Component lifecycle emitters + useEffect(() => { + // Error + if (lifecycleStatus.statusName === 'error') { + onError?.(lifecycleStatus.statusData); + } + // Success + if ( + lifecycleStatus.statusName === 'success' && + lifecycleStatus?.statusData.transactionReceipt + ) { + onSuccess?.(lifecycleStatus?.statusData.transactionReceipt); + setTransactionHash( + lifecycleStatus.statusData.transactionReceipt?.transactionHash, + ); + setHasHandledSuccess(true); + } + // Emit Status + onStatus?.(lifecycleStatus); + }, [ + onError, + onStatus, + onSuccess, + lifecycleStatus, + lifecycleStatus.statusData, // Keep statusData, so that the effect runs when it changes + lifecycleStatus.statusName, // Keep statusName, so that the effect runs when it changes + ]); + + useEffect(() => { + // Reset inputs after status reset. `resetInputs` is dependent + // on 'from' and 'to' so moved to separate useEffect to + // prevents multiple calls to `onStatus` + if (lifecycleStatus.statusName === 'init' && hasHandledSuccess) { + setHasHandledSuccess(false); + resetInputs(); + } + }, [hasHandledSuccess, lifecycleStatus.statusName, resetInputs]); + + useEffect(() => { + // For batched transactions, `transactionApproved` will contain the calls ID + // We'll use the `useAwaitCalls` hook to listen to the call status from the wallet server + // This will update the lifecycle status to `success` once the calls are confirmed + if ( + lifecycleStatus.statusName === 'transactionApproved' && + lifecycleStatus.statusData.transactionType === 'Batched' + ) { + awaitCallsStatus(); + } + }, [ + awaitCallsStatus, + lifecycleStatus, + lifecycleStatus.statusData, + lifecycleStatus.statusName, + ]); + + useEffect(() => { + let timer: NodeJS.Timeout; + // Reset status to init after success has been handled + if (lifecycleStatus.statusName === 'success' && hasHandledSuccess) { + timer = setTimeout(() => { + updateLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: true, + maxSlippage: config.maxSlippage, + }, + }); + }, 3000); + } + return () => { + if (timer) { + return clearTimeout(timer); + } + }; + }, [ + config.maxSlippage, + hasHandledSuccess, + lifecycleStatus.statusName, + updateLifecycleStatus, + ]); + + const handleAmountChange = useCallback( + async ( + amount: string, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component + ) => { + if ( + to.token === undefined || + fromETH.token === undefined || + fromUSDC.token === undefined + ) { + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + amountETH: fromETH.amount, + amountUSDC: fromUSDC.amount, + amountTo: to.amount, + tokenTo: to.token, + isMissingRequiredField: true, + }, + }); + return; + } + + if (amount === '' || amount === '.' || Number.parseFloat(amount) === 0) { + to.setAmount(''); + to.setAmountUSD(''); + fromETH.setAmountUSD(''); + fromUSDC.setAmountUSD(''); + from?.setAmountUSD(''); + return; + } + + fromETH.setLoading(true); + fromUSDC.setLoading(true); + from?.setLoading(true); + + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + // when fetching quote, the previous + // amount is irrelevant + amountTo: amount, + amountETH: '', + amountUSDC: '', + amountFrom: '', + tokenFromETH: fromETH.token, + tokenFromUSDC: fromUSDC.token, + tokenFrom: from?.token, + tokenTo: to.token, + // when fetching quote, the destination + // amount is missing + isMissingRequiredField: true, + }, + }); + + try { + const maxSlippage = lifecycleStatus.statusData.maxSlippage; + + const { + response: responseETH, + formattedFromAmount: formattedAmountETH, + } = await getBuyQuote({ + amount, + amountReference: 'to', + from: fromETH.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + fromSwapUnit: fromETH, + }); + + const { + response: responseUSDC, + formattedFromAmount: formattedAmountUSDC, + } = await getBuyQuote({ + amount, + amountReference: 'to', + from: fromUSDC.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + fromSwapUnit: fromUSDC, + }); + + const { + response: responseFrom, + formattedFromAmount: formattedAmountFrom, + } = await getBuyQuote({ + amount, + amountReference: 'to', + from: from?.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + fromSwapUnit: from, + }); + + if (!isSwapError(responseETH) && responseETH?.toAmountUSD) { + to.setAmountUSD(responseETH?.toAmountUSD); + } else if (!isSwapError(responseUSDC) && responseUSDC?.toAmountUSD) { + to.setAmountUSD(responseUSDC.toAmountUSD); + } else if (!isSwapError(responseFrom) && responseFrom?.toAmountUSD) { + to.setAmountUSD(responseFrom.toAmountUSD); + } else { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmSPc01', // Transaction module SwapProvider component 01 error + error: 'No valid quote found', + message: '', + }, + }); + return; + } + + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + amountETH: formattedAmountETH, + amountUSDC: formattedAmountUSDC, + amountFrom: formattedAmountFrom || '', + amountTo: amount, + tokenFromETH: fromETH.token, + tokenFromUSDC: fromUSDC.token, + tokenFrom: from?.token, + tokenTo: to.token, + // if quote was fetched successfully, we + // have all required fields + isMissingRequiredField: !formattedAmountETH, + }, + }); + } catch (err) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmSPc01', // Transaction module SwapProvider component 01 error + error: JSON.stringify(err), + message: '', + }, + }); + } finally { + // reset loading state when quote request resolves + fromETH.setLoading(false); + fromUSDC.setLoading(false); + from?.setLoading(false); + } + }, + [ + to, + from, + fromETH, + fromUSDC, + useAggregator, + updateLifecycleStatus, + lifecycleStatus.statusData.maxSlippage, + ], + ); + + const handleSubmit = useCallback( + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component + async (from: SwapUnit) => { + if (!address || !from.token || !to.token || !from.amount) { + return; + } + + try { + const maxSlippage = lifecycleStatus.statusData.maxSlippage; + const response = await buildSwapTransaction({ + amount: from.amount, + fromAddress: address, + from: from.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + }); + if (isSwapError(response)) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: response.code, + error: response.error, + message: response.message, + }, + }); + return; + } + await processSwapTransaction({ + chainId, + config: accountConfig, + isSponsored, + paymaster: paymaster || '', + sendCallsAsync, + sendTransactionAsync, + swapTransaction: response, + switchChainAsync, + updateLifecycleStatus, + useAggregator, + walletCapabilities, + }); + } catch (err) { + const errorMessage = isUserRejectedRequestError(err) + ? 'Request denied.' + : GENERIC_ERROR_MESSAGE; + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmSPc02', // Transaction module SwapProvider component 02 error + error: JSON.stringify(err), + message: errorMessage, + }, + }); + } + }, + [ + accountConfig, + address, + chainId, + isSponsored, + lifecycleStatus, + paymaster, + sendCallsAsync, + sendTransactionAsync, + switchChainAsync, + to.token, + updateLifecycleStatus, + useAggregator, + walletCapabilities, + ], + ); + + const value = useValue({ + address, + config, + from, + fromETH, + fromUSDC, + handleAmountChange, + handleSubmit, + lifecycleStatus, + updateLifecycleStatus, + to, + setTransactionHash, + transactionHash, + isDropdownOpen, + setIsDropdownOpen, + toToken, + fromToken, + projectId, + startPopupMonitor, + }); + + return {children}; +} diff --git a/src/buy/components/BuyTokenItem.tsx b/src/buy/components/BuyTokenItem.tsx new file mode 100644 index 0000000000..b130c73fa7 --- /dev/null +++ b/src/buy/components/BuyTokenItem.tsx @@ -0,0 +1,58 @@ +import { useCallback, useMemo } from 'react'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; +import { cn, color } from '../../styles/theme'; +import { TokenImage } from '../../token'; +import type { SwapUnit } from '../../swap/types'; +import { useBuyContext } from './BuyProvider'; + +export function BuyTokenItem({ swapUnit }: { swapUnit?: SwapUnit }) { + const { handleSubmit, setIsDropdownOpen } = useBuyContext(); + + if (!swapUnit || !swapUnit.token) { + return null; + } + + const handleClick = useCallback(() => { + setIsDropdownOpen(false); + handleSubmit(swapUnit); + }, [handleSubmit, swapUnit, setIsDropdownOpen]); + + const hasInsufficientBalance = + !swapUnit.balance || + Number.parseFloat(swapUnit.balance) < Number.parseFloat(swapUnit.amount); + + const roundedAmount = useMemo(() => { + return getRoundedAmount(swapUnit.amount, 10); + }, [swapUnit.amount]); + + const roundedBalance = useMemo(() => { + return getRoundedAmount(swapUnit.balance || '0', 10); + }, [swapUnit.balance]); + + return ( + + ); +} diff --git a/src/buy/constants.ts b/src/buy/constants.ts new file mode 100644 index 0000000000..513ed97a2d --- /dev/null +++ b/src/buy/constants.ts @@ -0,0 +1,20 @@ +export const ONRAMP_PAYMENT_METHODS = [ + { + id: 'CRYPTO_ACCOUNT', + name: 'Coinbase', + description: 'Buy with your Coinbase account', + icon: 'coinbasePay', + }, + { + id: 'APPLE_PAY', + name: 'Apple Pay', + description: 'Up to $500/week', + icon: 'applePay', + }, + { + id: 'CARD', + name: 'Debit Card', + description: 'Up to $500/week', + icon: 'creditCard', + }, +]; diff --git a/src/buy/hooks/usePopupMonitor.ts b/src/buy/hooks/usePopupMonitor.ts new file mode 100644 index 0000000000..c9414541be --- /dev/null +++ b/src/buy/hooks/usePopupMonitor.ts @@ -0,0 +1,37 @@ +import { useRef, useEffect, useCallback } from 'react'; + +export const usePopupMonitor = (onClose?: () => void) => { + const intervalRef = useRef(null); + + // Start monitoring the popup + const startPopupMonitor = useCallback((popupWindow: Window) => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + intervalRef.current = window.setInterval(() => { + if (popupWindow.closed) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + console.log('Popup closed'); + onClose?.(); + } + }, 500); + }, []); + + // Stop monitoring the popup + const stopPopupMonitor = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + // Cleanup interval on unmount + useEffect(() => { + return () => stopPopupMonitor(); + }, [stopPopupMonitor]); + + return { startPopupMonitor }; +}; diff --git a/src/buy/index.ts b/src/buy/index.ts new file mode 100644 index 0000000000..bed8f927a9 --- /dev/null +++ b/src/buy/index.ts @@ -0,0 +1,2 @@ +// 🌲☀🌲 +export { Buy } from './components/Buy'; diff --git a/src/buy/types.ts b/src/buy/types.ts new file mode 100644 index 0000000000..69ca491c0f --- /dev/null +++ b/src/buy/types.ts @@ -0,0 +1,62 @@ +import { + LifecycleStatus, + LifecycleStatusUpdate, + SwapConfig, + SwapError, + SwapUnit, +} from '@/swap/types'; +import { Token } from '@/token'; +import { Address, TransactionReceipt } from 'viem'; + +export type BuyReact = { + className?: string; // Optional className override for top div element. + config?: SwapConfig; + experimental?: { + useAggregator: boolean; // Whether to use a DEX aggregator. (default: true) + }; + isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) + onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. + onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state + onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + fromToken?: Token; + toToken: Token; + projectId: string; // Your CDP project ID found at https://portal.cdp.coinbase.com/ +}; + +export type BuyContextType = { + address?: Address; // Used to check if user is connected in SwapButton + config: SwapConfig; + fromETH: SwapUnit; + fromUSDC: SwapUnit; + lifecycleStatus: LifecycleStatus; + handleAmountChange: (amount: string) => void; + handleSubmit: (fromToken: SwapUnit) => void; + updateLifecycleStatus: (state: LifecycleStatusUpdate) => void; // A function to set the lifecycle status of the component + setTransactionHash: (hash: string) => void; + fromToken?: Token; + to?: SwapUnit; + from?: SwapUnit; + toToken: Token; + transactionHash: string; + isDropdownOpen: boolean; + setIsDropdownOpen: (open: boolean) => void; + projectId: string; + startPopupMonitor: (popupWindow: Window) => void; +}; + +export type BuyProviderReact = { + children: React.ReactNode; + config?: { + maxSlippage: number; // Maximum acceptable slippage for a swap. (default: 10) This is as a percent, not basis points + }; + experimental: { + useAggregator: boolean; // Whether to use a DEX aggregator. (default: true) + }; + isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) + onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. + onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state + onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + fromToken?: Token; + toToken: Token; + projectId: string; +}; diff --git a/src/buy/utils/getBuyQuote.ts b/src/buy/utils/getBuyQuote.ts new file mode 100644 index 0000000000..0234c3dec7 --- /dev/null +++ b/src/buy/utils/getBuyQuote.ts @@ -0,0 +1,71 @@ +import { getSwapQuote } from '@/core/api/getSwapQuote'; +import type { + APIError, + GetSwapQuoteParams, + GetSwapQuoteResponse, +} from '@/core/api/types'; +import { formatTokenAmount } from '@/core/utils/formatTokenAmount'; +import type { SwapError, SwapUnit } from '../../swap/types'; +import { isSwapError } from '../../swap/utils/isSwapError'; +import type { Token } from '../../token'; + +type GetBuyQuoteResponse = { + response?: GetSwapQuoteResponse; + error?: APIError; + formattedFromAmount?: string; +}; + +type GetBuyQuoteParams = Omit & { + fromSwapUnit?: SwapUnit; + from?: Token; +}; + +/** + * Fetches a quote for a swap, but only if the from and to tokens are different. + */ + +export async function getBuyQuote({ + amount, + amountReference, + from, + maxSlippage, + to, + useAggregator, + fromSwapUnit, +}: GetBuyQuoteParams): Promise { + // only fetch quote if the from token is provided + if (!from) { + return { response: undefined, formattedFromAmount: '', error: undefined }; + } + + let response: GetSwapQuoteResponse | undefined; + // only fetch quote if the from and to tokens are different + if (to?.symbol !== from?.symbol) { + response = await getSwapQuote({ + amount, + amountReference, + from, + maxSlippage, + to, + useAggregator, + }); + } + + let formattedFromAmount = ''; + if (response && !isSwapError(response)) { + formattedFromAmount = response?.fromAmount + ? formatTokenAmount(response.fromAmount, response.from.decimals) + : ''; + + fromSwapUnit?.setAmountUSD(response?.fromAmountUSD || ''); + fromSwapUnit?.setAmount(formattedFromAmount || ''); + } + + let error: SwapError | undefined; + if (isSwapError(response)) { + error = response; + response = undefined; + } + + return { response, formattedFromAmount, error }; +} diff --git a/src/swap/index.ts b/src/swap/index.ts index a8b9abdccb..984438fa9d 100644 --- a/src/swap/index.ts +++ b/src/swap/index.ts @@ -3,7 +3,6 @@ export { Swap } from './components/Swap'; export { SwapAmountInput } from './components/SwapAmountInput'; export { SwapButton } from './components/SwapButton'; export { SwapDefault } from './components/SwapDefault'; -export { SwapLite } from './components/SwapLite'; export { SwapMessage } from './components/SwapMessage'; export { SwapSettings } from './components/SwapSettings'; export { SwapSettingsSlippageDescription } from './components/SwapSettingsSlippageDescription';