diff --git a/.changeset/nine-seahorses-judge.md b/.changeset/nine-seahorses-judge.md new file mode 100644 index 00000000000..e746319233d --- /dev/null +++ b/.changeset/nine-seahorses-judge.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Allow editing pay amount mid flow diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/BuyScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/BuyScreen.tsx index 2e7c868af16..6b4b6d10748 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/BuyScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/BuyScreen.tsx @@ -1,50 +1,32 @@ -import { ChevronDownIcon } from "@radix-ui/react-icons"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; import type { Chain } from "../../../../../../chains/types.js"; -import { getCachedChain } from "../../../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../../../client/client.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../../constants/addresses.js"; -import { getContract } from "../../../../../../contract/contract.js"; -import { allowance } from "../../../../../../extensions/erc20/__generated__/IERC20/read/allowance.js"; -import type { GetBuyWithCryptoQuoteParams } from "../../../../../../pay/buyWithCrypto/getQuote.js"; import type { BuyWithCryptoStatus } from "../../../../../../pay/buyWithCrypto/getStatus.js"; import type { BuyWithFiatStatus } from "../../../../../../pay/buyWithFiat/getStatus.js"; -import { isSwapRequiredPostOnramp } from "../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js"; -import type { FiatProvider } from "../../../../../../pay/utils/commonTypes.js"; -import { formatNumber } from "../../../../../../utils/formatNumber.js"; import type { Account } from "../../../../../../wallets/interfaces/wallet.js"; import type { WalletId } from "../../../../../../wallets/wallet-types.js"; import { type Theme, - iconSize, + fontSize, spacing, } from "../../../../../core/design-system/index.js"; import type { FundWalletOptions, PayUIOptions, } from "../../../../../core/hooks/connection/ConnectButtonProps.js"; -import { useWalletBalance } from "../../../../../core/hooks/others/useWalletBalance.js"; -import { useBuyWithCryptoQuote } from "../../../../../core/hooks/pay/useBuyWithCryptoQuote.js"; -import { useBuyWithFiatQuote } from "../../../../../core/hooks/pay/useBuyWithFiatQuote.js"; import { useActiveAccount } from "../../../../../core/hooks/wallets/useActiveAccount.js"; import { invalidateWalletBalance } from "../../../../../core/providers/invalidateWalletBalance.js"; import type { SupportedTokens } from "../../../../../core/utils/defaultTokens.js"; -import { PREFERRED_FIAT_PROVIDER_STORAGE_KEY } from "../../../../../core/utils/storage.js"; import { ErrorState } from "../../../../wallets/shared/ErrorState.js"; import { LoadingScreen } from "../../../../wallets/shared/LoadingScreen.js"; import type { PayEmbedConnectOptions } from "../../../PayEmbed.js"; import { ChainName } from "../../../components/ChainName.js"; -import { - Drawer, - DrawerOverlay, - useDrawer, -} from "../../../components/Drawer.js"; import { Spacer } from "../../../components/Spacer.js"; -import { Spinner } from "../../../components/Spinner.js"; -import { SwitchNetworkButton } from "../../../components/SwitchNetwork.js"; import { Container, Line, ModalHeader } from "../../../components/basic.js"; import { Button } from "../../../components/buttons.js"; +import { Input } from "../../../components/formElements.js"; import { Text } from "../../../components/text.js"; import { TokenSymbol } from "../../../components/token/TokenSymbol.js"; import { ConnectButton } from "../../ConnectButton.js"; @@ -54,14 +36,11 @@ import { TokenSelector } from "../TokenSelector.js"; import { WalletSwitcherConnectionScreen } from "../WalletSwitcherConnectionScreen.js"; import { type ERC20OrNativeToken, isNativeToken } from "../nativeToken.js"; import { DirectPaymentModeScreen } from "./DirectPaymentModeScreen.js"; -import { EstimatedTimeAndFees } from "./EstimatedTimeAndFees.js"; import { PayTokenIcon } from "./PayTokenIcon.js"; -import { PayWithCreditCard } from "./PayWIthCreditCard.js"; import { TransactionModeScreen } from "./TransactionModeScreen.js"; import { CurrencySelection } from "./fiat/CurrencySelection.js"; import { FiatFlow } from "./fiat/FiatFlow.js"; -import { Providers } from "./fiat/Providers.js"; -import type { CurrencyMeta } from "./fiat/currencies.js"; +import { FiatScreenContent } from "./fiat/FiatScreenContent.js"; import type { SelectedScreen } from "./main/types.js"; import { type PaymentMethods, @@ -72,20 +51,17 @@ import { useFromTokenSelectionStates, useToTokenSelectionStates, } from "./main/useUISelectionStates.js"; -import { openOnrampPopup } from "./openOnRamppopup.js"; import { BuyTokenInput } from "./swap/BuyTokenInput.js"; -import { FiatFees, SwapFees } from "./swap/Fees.js"; -import { PayWithCryptoQuoteInfo } from "./swap/PayWithCrypto.js"; +import {} from "./swap/Fees.js"; import { PaymentSelectionScreen } from "./swap/PaymentSelectionScreen.js"; import { SwapFlow } from "./swap/SwapFlow.js"; +import { SwapScreenContent } from "./swap/SwapScreenContent.js"; import { TransferFlow } from "./swap/TransferFlow.js"; -import { addPendingTx } from "./swap/pendingSwapTx.js"; import { type SupportedChainAndTokens, useBuySupportedDestinations, useBuySupportedSources, } from "./swap/useSwapSupportedChains.js"; -import type { PayerInfo } from "./types.js"; import { usePayerSetup } from "./usePayerSetup.js"; export type BuyScreenProps = { @@ -545,6 +521,7 @@ function BuyScreenContent(props: BuyScreenContentProps) { selectedChain={toChain} selectedToken={toToken} tokenAmount={tokenAmount} + setTokenAmount={setTokenAmount} client={client} onBack={() => { if ( @@ -672,8 +649,17 @@ function SelectedTokenInfo(props: { selectedToken: ERC20OrNativeToken; selectedChain: Chain; tokenAmount: string; + setTokenAmount: (amount: string) => void; client: ThirdwebClient; }) { + const getWidth = () => { + let chars = props.tokenAmount.replace(".", "").length; + const hasDot = props.tokenAmount.includes("."); + if (hasDot) { + chars += 0.3; + } + return `calc(${`${Math.max(1, chars)}ch + 2px`})`; + }; return (
- - {formatNumber(Number(props.tokenAmount), 6)} - + { + // put cursor at the end of the input + if (props.tokenAmount === "") { + e.currentTarget.setSelectionRange( + e.currentTarget.value.length, + e.currentTarget.value.length, + ); + } + }} + onChange={(e) => { + let value = e.target.value; + + if (value.startsWith(".")) { + value = `0${value}`; + } + + if (value.length > 10) { + return; + } + + const numValue = Number(value); + if (Number.isNaN(numValue)) { + return; + } + + if (value.startsWith("0") && !value.startsWith("0.")) { + props.setTokenAmount(value.slice(1)); + } else { + props.setTokenAmount(value); + } + }} + style={{ + border: "none", + fontSize: fontSize.lg, + boxShadow: "none", + borderRadius: "0", + padding: "0", + paddingBlock: "2px", + fontWeight: 600, + textAlign: "left", + width: getWidth(), + }} + /> void; selectedToken: ERC20OrNativeToken; selectedChain: Chain; client: ThirdwebClient; @@ -897,6 +934,7 @@ function TokenSelectedLayout(props: { selectedToken={props.selectedToken} selectedChain={props.selectedChain} tokenAmount={props.tokenAmount} + setTokenAmount={props.setTokenAmount} client={props.client} /> @@ -913,587 +951,6 @@ function TokenSelectedLayout(props: { ); } -function SwapScreenContent(props: { - setScreen: (screen: SelectedScreen) => void; - tokenAmount: string; - toToken: ERC20OrNativeToken; - toChain: Chain; - fromChain: Chain; - fromToken: ERC20OrNativeToken; - showFromTokenSelector: () => void; - payer: PayerInfo; - client: ThirdwebClient; - payOptions: PayUIOptions; - isEmbed: boolean; - onDone: () => void; - connectOptions: PayEmbedConnectOptions | undefined; - connectLocale: ConnectLocale; - setPayer: (payer: PayerInfo) => void; - activeAccount: Account; - setTokenAmount: (amount: string) => void; - setHasEditedAmount: (hasEdited: boolean) => void; - disableTokenSelection: boolean; -}) { - const { - setScreen, - payer, - client, - toChain, - tokenAmount, - toToken, - fromChain, - fromToken, - payOptions, - disableTokenSelection, - } = props; - - const defaultRecipientAddress = ( - props.payOptions as Extract - )?.paymentInfo?.sellerAddress; - const receiverAddress = - defaultRecipientAddress || props.activeAccount.address; - const { drawerRef, drawerOverlayRef, isOpen, setIsOpen } = useDrawer(); - const [drawerScreen, setDrawerScreen] = useState< - "fees" | "receiver" | "payer" - >("fees"); - - const fromTokenBalanceQuery = useWalletBalance({ - address: payer.account.address, - chain: fromChain, - tokenAddress: isNativeToken(fromToken) ? undefined : fromToken.address, - client, - }); - - const fromTokenId = isNativeToken(fromToken) - ? NATIVE_TOKEN_ADDRESS - : fromToken.address.toLowerCase(); - const toTokenId = isNativeToken(toToken) - ? NATIVE_TOKEN_ADDRESS - : toToken.address.toLowerCase(); - const swapRequired = - !!tokenAmount && - !(fromChain.id === toChain.id && fromTokenId === toTokenId); - const quoteParams: GetBuyWithCryptoQuoteParams | undefined = swapRequired - ? { - // wallets - fromAddress: payer.account.address, - toAddress: receiverAddress, - // from - fromChainId: fromChain.id, - fromTokenAddress: isNativeToken(fromToken) - ? NATIVE_TOKEN_ADDRESS - : fromToken.address, - // to - toChainId: toChain.id, - toTokenAddress: isNativeToken(toToken) - ? NATIVE_TOKEN_ADDRESS - : toToken.address, - toAmount: tokenAmount, - client, - purchaseData: payOptions.purchaseData, - } - : undefined; - - const quoteQuery = useBuyWithCryptoQuote(quoteParams, { - // refetch every 30 seconds - staleTime: 30 * 1000, - refetchInterval: 30 * 1000, - gcTime: 30 * 1000, - }); - - const allowanceQuery = useQuery({ - queryKey: [ - "allowance", - payer.account.address, - quoteQuery.data?.approvalData, - ], - queryFn: () => { - if (!quoteQuery.data?.approvalData) { - return null; - } - return allowance({ - contract: getContract({ - client: props.client, - address: quoteQuery.data.swapDetails.fromToken.tokenAddress, - chain: getCachedChain(quoteQuery.data.swapDetails.fromToken.chainId), - }), - spender: quoteQuery.data.approvalData.spenderAddress, - owner: props.payer.account.address, - }); - }, - enabled: !!quoteQuery.data?.approvalData, - refetchOnMount: true, - }); - - const sourceTokenAmount = swapRequired - ? quoteQuery.data?.swapDetails.fromAmount - : tokenAmount; - - const isNotEnoughBalance = - !!sourceTokenAmount && - !!fromTokenBalanceQuery.data && - Number(fromTokenBalanceQuery.data.displayValue) < Number(sourceTokenAmount); - - const disableContinue = - (swapRequired && !quoteQuery.data) || - isNotEnoughBalance || - allowanceQuery.isLoading; - const switchChainRequired = - props.payer.wallet.getChain()?.id !== fromChain.id; - - const errorMsg = - !quoteQuery.isLoading && quoteQuery.error - ? getErrorMessage(quoteQuery.error) - : undefined; - - function showSwapFlow() { - if ( - (props.payOptions.mode === "direct_payment" || - props.payOptions.mode === "fund_wallet") && - !isNotEnoughBalance && - !swapRequired - ) { - // same currency, just direct transfer - setScreen({ - id: "transfer-flow", - }); - } else if ( - props.payOptions.mode === "transaction" && - !isNotEnoughBalance && - !swapRequired - ) { - if (payer.account.address !== receiverAddress) { - // needs transfer from another wallet before executing the transaction - setScreen({ - id: "transfer-flow", - }); - } else { - // has enough balance to just do the transaction directly - props.onDone(); - } - - return; - } - - if (!quoteQuery.data) { - return; - } - - setScreen({ - id: "swap-flow", - quote: quoteQuery.data, - approvalAmount: allowanceQuery.data ?? undefined, - }); - } - - function showFees() { - if (!quoteQuery.data) { - return; - } - - setIsOpen(true); - setDrawerScreen("fees"); - } - - return ( - - {isOpen && ( - <> - - setIsOpen(false)}> - {drawerScreen === "fees" && quoteQuery.data && ( -
- - Fees - - - -
- )} -
- - )} - - {/* Quote info */} -
- - {swapRequired && ( - - )} - -
- - {/* Error message */} - {errorMsg && ( -
- {errorMsg.data?.minimumAmountEth ? ( - - Minimum amount is{" "} - {formatNumber(Number(errorMsg.data.minimumAmountEth), 6)}{" "} - - - ) : ( - - {errorMsg.message || defaultMessage} - - )} -
- )} - - {!errorMsg && isNotEnoughBalance && ( -
- - Not enough funds. - - - Try a different wallet or token. - -
- )} - - {/* Button */} - {errorMsg?.data?.minimumAmountEth ? ( - - ) : switchChainRequired && - !quoteQuery.isLoading && - !allowanceQuery.isLoading && - !isNotEnoughBalance && - !quoteQuery.error ? ( - { - await props.payer.wallet.switchChain(fromChain); - }} - /> - ) : ( - - )} -
- ); -} - -function FiatScreenContent(props: { - setScreen: (screen: SelectedScreen) => void; - tokenAmount: string; - toToken: ERC20OrNativeToken; - toChain: Chain; - selectedCurrency: CurrencyMeta; - showCurrencySelector: () => void; - payOptions: PayUIOptions; - theme: "light" | "dark" | Theme; - client: ThirdwebClient; - onDone: () => void; - isEmbed: boolean; - payer: PayerInfo; - setTokenAmount: (amount: string) => void; - setHasEditedAmount: (hasEdited: boolean) => void; -}) { - const { - toToken, - tokenAmount, - payer, - client, - setScreen, - toChain, - showCurrencySelector, - selectedCurrency, - } = props; - const defaultRecipientAddress = ( - props.payOptions as Extract - )?.paymentInfo?.sellerAddress; - const receiverAddress = - defaultRecipientAddress || props.payer.account.address; - const { drawerRef, drawerOverlayRef, isOpen, setIsOpen } = useDrawer(); - const [drawerScreen, setDrawerScreen] = useState<"fees" | "providers">( - "fees", - ); - - const buyWithFiatOptions = props.payOptions.buyWithFiat; - const [preferredProvider, setPreferredProvider] = useState< - FiatProvider | undefined - >( - buyWithFiatOptions !== false - ? buyWithFiatOptions?.preferredProvider || - ((localStorage.getItem( - PREFERRED_FIAT_PROVIDER_STORAGE_KEY, - ) as FiatProvider | null) ?? - undefined) - : undefined, - ); - - const fiatQuoteQuery = useBuyWithFiatQuote( - buyWithFiatOptions !== false && tokenAmount - ? { - fromCurrencySymbol: selectedCurrency.shorthand, - toChainId: toChain.id, - toAddress: receiverAddress, - toTokenAddress: isNativeToken(toToken) - ? NATIVE_TOKEN_ADDRESS - : toToken.address, - toAmount: tokenAmount, - client, - isTestMode: buyWithFiatOptions?.testMode, - purchaseData: props.payOptions.purchaseData, - fromAddress: payer.account.address, - preferredProvider: preferredProvider, - } - : undefined, - ); - - function handleSubmit() { - if (!fiatQuoteQuery.data) { - return; - } - - const hasTwoSteps = isSwapRequiredPostOnramp(fiatQuoteQuery.data); - let openedWindow: Window | null = null; - - if (!hasTwoSteps) { - openedWindow = openOnrampPopup( - fiatQuoteQuery.data.onRampLink, - typeof props.theme === "string" ? props.theme : props.theme.type, - ); - - addPendingTx({ - type: "fiat", - intentId: fiatQuoteQuery.data.intentId, - }); - } - - setScreen({ - id: "fiat-flow", - quote: fiatQuoteQuery.data, - openedWindow, - }); - } - - function showFees() { - if (!fiatQuoteQuery.data) { - return; - } - - setDrawerScreen("fees"); - setIsOpen(true); - } - - function showProviders() { - setDrawerScreen("providers"); - setIsOpen(true); - } - - const disableSubmit = !fiatQuoteQuery.data; - - const errorMsg = - !fiatQuoteQuery.isLoading && fiatQuoteQuery.error - ? getErrorMessage(fiatQuoteQuery.error) - : undefined; - - return ( - - {isOpen && ( - <> - - setIsOpen(false)}> - {drawerScreen === "fees" && fiatQuoteQuery.data && ( -
- - Fees - - - - -
- )} - {drawerScreen === "providers" && ( -
- - Providers - - - { - setPreferredProvider(provider); - // save the pref in local storage - localStorage.setItem( - PREFERRED_FIAT_PROVIDER_STORAGE_KEY, - provider, - ); - setIsOpen(false); - }} - /> -
- )} -
- - )} - -
- - - - Provider - - - - {/* Estimated time + View fees button */} - - -
- - {/* Error message */} - {errorMsg && ( -
- {errorMsg.data?.minimumAmountEth ? ( - - Minimum amount is{" "} - {formatNumber(Number(errorMsg.data.minimumAmountEth), 6)}{" "} - - - ) : ( - - {errorMsg.message || defaultMessage} - - )} -
- )} - - {errorMsg?.data?.minimumAmountEth ? ( - - ) : ( - - )} -
- ); -} - function createSupportedTokens( data: SupportedChainAndTokens, payOptions: PayUIOptions, @@ -1584,26 +1041,3 @@ function ChainSelectionScreen(props: { /> ); } - -type ApiError = { - code: string; - message?: string; - data?: { - minimumAmountUSDCents?: string; - requestedAmountUSDCents?: string; - minimumAmountWei?: string; - minimumAmountEth?: string; - }; -}; - -const defaultMessage = "Unable to get price quote"; -// biome-ignore lint/suspicious/noExplicitAny: -function getErrorMessage(err: any): ApiError { - if (typeof err.error === "object" && err.error.code) { - return err.error; - } - return { - code: "UNABLE_TO_GET_PRICE_QUOTE", - message: defaultMessage, - }; -} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatScreenContent.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatScreenContent.tsx new file mode 100644 index 00000000000..0458626ef90 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatScreenContent.tsx @@ -0,0 +1,307 @@ +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { useState } from "react"; +import type { Chain } from "../../../../../../../chains/types.js"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js"; +import { isSwapRequiredPostOnramp } from "../../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js"; +import type { FiatProvider } from "../../../../../../../pay/utils/commonTypes.js"; +import { formatNumber } from "../../../../../../../utils/formatNumber.js"; +import { + type Theme, + iconSize, + spacing, +} from "../../../../../../core/design-system/index.js"; +import type { PayUIOptions } from "../../../../../../core/hooks/connection/ConnectButtonProps.js"; +import { useBuyWithFiatQuote } from "../../../../../../core/hooks/pay/useBuyWithFiatQuote.js"; +import { PREFERRED_FIAT_PROVIDER_STORAGE_KEY } from "../../../../../../core/utils/storage.js"; +import { + defaultMessage, + getErrorMessage, +} from "../../../../../utils/errors.js"; +import { + Drawer, + DrawerOverlay, + useDrawer, +} from "../../../../components/Drawer.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Spinner } from "../../../../components/Spinner.js"; +import { Container } from "../../../../components/basic.js"; +import { Button } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { TokenSymbol } from "../../../../components/token/TokenSymbol.js"; +import { type ERC20OrNativeToken, isNativeToken } from "../../nativeToken.js"; +import { EstimatedTimeAndFees } from "../EstimatedTimeAndFees.js"; +import { PayWithCreditCard } from "../PayWIthCreditCard.js"; +import type { SelectedScreen } from "../main/types.js"; +import { openOnrampPopup } from "../openOnRamppopup.js"; +import { FiatFees } from "../swap/Fees.js"; +import { addPendingTx } from "../swap/pendingSwapTx.js"; +import type { PayerInfo } from "../types.js"; +import { Providers } from "./Providers.js"; +import type { CurrencyMeta } from "./currencies.js"; + +export function FiatScreenContent(props: { + setScreen: (screen: SelectedScreen) => void; + tokenAmount: string; + toToken: ERC20OrNativeToken; + toChain: Chain; + selectedCurrency: CurrencyMeta; + showCurrencySelector: () => void; + payOptions: PayUIOptions; + theme: "light" | "dark" | Theme; + client: ThirdwebClient; + onDone: () => void; + isEmbed: boolean; + payer: PayerInfo; + setTokenAmount: (amount: string) => void; + setHasEditedAmount: (hasEdited: boolean) => void; +}) { + const { + toToken, + tokenAmount, + payer, + client, + setScreen, + toChain, + showCurrencySelector, + selectedCurrency, + } = props; + const defaultRecipientAddress = ( + props.payOptions as Extract + )?.paymentInfo?.sellerAddress; + const receiverAddress = + defaultRecipientAddress || props.payer.account.address; + const { drawerRef, drawerOverlayRef, isOpen, setIsOpen } = useDrawer(); + const [drawerScreen, setDrawerScreen] = useState<"fees" | "providers">( + "fees", + ); + + const buyWithFiatOptions = props.payOptions.buyWithFiat; + const [preferredProvider, setPreferredProvider] = useState< + FiatProvider | undefined + >( + buyWithFiatOptions !== false + ? buyWithFiatOptions?.preferredProvider || + ((localStorage.getItem( + PREFERRED_FIAT_PROVIDER_STORAGE_KEY, + ) as FiatProvider | null) ?? + undefined) + : undefined, + ); + + const fiatQuoteQuery = useBuyWithFiatQuote( + buyWithFiatOptions !== false && tokenAmount + ? { + fromCurrencySymbol: selectedCurrency.shorthand, + toChainId: toChain.id, + toAddress: receiverAddress, + toTokenAddress: isNativeToken(toToken) + ? NATIVE_TOKEN_ADDRESS + : toToken.address, + toAmount: tokenAmount, + client, + isTestMode: buyWithFiatOptions?.testMode, + purchaseData: props.payOptions.purchaseData, + fromAddress: payer.account.address, + preferredProvider: preferredProvider, + } + : undefined, + ); + + function handleSubmit() { + if (!fiatQuoteQuery.data) { + return; + } + + const hasTwoSteps = isSwapRequiredPostOnramp(fiatQuoteQuery.data); + let openedWindow: Window | null = null; + + if (!hasTwoSteps) { + openedWindow = openOnrampPopup( + fiatQuoteQuery.data.onRampLink, + typeof props.theme === "string" ? props.theme : props.theme.type, + ); + + addPendingTx({ + type: "fiat", + intentId: fiatQuoteQuery.data.intentId, + }); + } + + setScreen({ + id: "fiat-flow", + quote: fiatQuoteQuery.data, + openedWindow, + }); + } + + function showFees() { + if (!fiatQuoteQuery.data) { + return; + } + + setDrawerScreen("fees"); + setIsOpen(true); + } + + function showProviders() { + setDrawerScreen("providers"); + setIsOpen(true); + } + + const disableSubmit = !fiatQuoteQuery.data; + + const errorMsg = + !fiatQuoteQuery.isLoading && fiatQuoteQuery.error + ? getErrorMessage(fiatQuoteQuery.error) + : undefined; + + return ( + + {isOpen && ( + <> + + setIsOpen(false)}> + {drawerScreen === "fees" && fiatQuoteQuery.data && ( +
+ + Fees + + + + +
+ )} + {drawerScreen === "providers" && ( +
+ + Providers + + + { + setPreferredProvider(provider); + // save the pref in local storage + localStorage.setItem( + PREFERRED_FIAT_PROVIDER_STORAGE_KEY, + provider, + ); + setIsOpen(false); + }} + /> +
+ )} +
+ + )} + +
+ + + + Provider + + + + {/* Estimated time + View fees button */} + + +
+ + {/* Error message */} + {errorMsg && ( +
+ {errorMsg.data?.minimumAmountEth ? ( + + Minimum amount is{" "} + {formatNumber(Number(errorMsg.data.minimumAmountEth), 6)}{" "} + + + ) : ( + + {errorMsg.message || defaultMessage} + + )} +
+ )} + + {errorMsg?.data?.minimumAmountEth ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapScreenContent.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapScreenContent.tsx new file mode 100644 index 00000000000..851b1f2b955 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/SwapScreenContent.tsx @@ -0,0 +1,353 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import type { Chain } from "../../../../../../../chains/types.js"; +import { getCachedChain } from "../../../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js"; +import { getContract } from "../../../../../../../contract/contract.js"; +import { allowance } from "../../../../../../../extensions/erc20/__generated__/IERC20/read/allowance.js"; +import type { GetBuyWithCryptoQuoteParams } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; +import { formatNumber } from "../../../../../../../utils/formatNumber.js"; +import type { Account } from "../../../../../../../wallets/interfaces/wallet.js"; +import type { PayUIOptions } from "../../../../../../core/hooks/connection/ConnectButtonProps.js"; +import { useWalletBalance } from "../../../../../../core/hooks/others/useWalletBalance.js"; +import { useBuyWithCryptoQuote } from "../../../../../../core/hooks/pay/useBuyWithCryptoQuote.js"; +import { + defaultMessage, + getErrorMessage, +} from "../../../../../utils/errors.js"; +import type { PayEmbedConnectOptions } from "../../../../PayEmbed.js"; +import { + Drawer, + DrawerOverlay, + useDrawer, +} from "../../../../components/Drawer.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Spinner } from "../../../../components/Spinner.js"; +import { SwitchNetworkButton } from "../../../../components/SwitchNetwork.js"; +import { Container } from "../../../../components/basic.js"; +import { Button } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { TokenSymbol } from "../../../../components/token/TokenSymbol.js"; +import type { ConnectLocale } from "../../../locale/types.js"; +import { type ERC20OrNativeToken, isNativeToken } from "../../nativeToken.js"; +import { EstimatedTimeAndFees } from "../EstimatedTimeAndFees.js"; +import type { SelectedScreen } from "../main/types.js"; +import type { PayerInfo } from "../types.js"; +import { SwapFees } from "./Fees.js"; +import { PayWithCryptoQuoteInfo } from "./PayWithCrypto.js"; + +export function SwapScreenContent(props: { + setScreen: (screen: SelectedScreen) => void; + tokenAmount: string; + toToken: ERC20OrNativeToken; + toChain: Chain; + fromChain: Chain; + fromToken: ERC20OrNativeToken; + showFromTokenSelector: () => void; + payer: PayerInfo; + client: ThirdwebClient; + payOptions: PayUIOptions; + isEmbed: boolean; + onDone: () => void; + connectOptions: PayEmbedConnectOptions | undefined; + connectLocale: ConnectLocale; + setPayer: (payer: PayerInfo) => void; + activeAccount: Account; + setTokenAmount: (amount: string) => void; + setHasEditedAmount: (hasEdited: boolean) => void; + disableTokenSelection: boolean; +}) { + const { + setScreen, + payer, + client, + toChain, + tokenAmount, + toToken, + fromChain, + fromToken, + payOptions, + disableTokenSelection, + } = props; + + const defaultRecipientAddress = ( + props.payOptions as Extract + )?.paymentInfo?.sellerAddress; + const receiverAddress = + defaultRecipientAddress || props.activeAccount.address; + const { drawerRef, drawerOverlayRef, isOpen, setIsOpen } = useDrawer(); + const [drawerScreen, setDrawerScreen] = useState< + "fees" | "receiver" | "payer" + >("fees"); + + const fromTokenBalanceQuery = useWalletBalance({ + address: payer.account.address, + chain: fromChain, + tokenAddress: isNativeToken(fromToken) ? undefined : fromToken.address, + client, + }); + + const fromTokenId = isNativeToken(fromToken) + ? NATIVE_TOKEN_ADDRESS + : fromToken.address.toLowerCase(); + const toTokenId = isNativeToken(toToken) + ? NATIVE_TOKEN_ADDRESS + : toToken.address.toLowerCase(); + const swapRequired = + !!tokenAmount && + !(fromChain.id === toChain.id && fromTokenId === toTokenId); + const quoteParams: GetBuyWithCryptoQuoteParams | undefined = swapRequired + ? { + // wallets + fromAddress: payer.account.address, + toAddress: receiverAddress, + // from + fromChainId: fromChain.id, + fromTokenAddress: isNativeToken(fromToken) + ? NATIVE_TOKEN_ADDRESS + : fromToken.address, + // to + toChainId: toChain.id, + toTokenAddress: isNativeToken(toToken) + ? NATIVE_TOKEN_ADDRESS + : toToken.address, + toAmount: tokenAmount, + client, + purchaseData: payOptions.purchaseData, + } + : undefined; + + const quoteQuery = useBuyWithCryptoQuote(quoteParams, { + // refetch every 30 seconds + staleTime: 30 * 1000, + refetchInterval: 30 * 1000, + gcTime: 30 * 1000, + }); + + const allowanceQuery = useQuery({ + queryKey: [ + "allowance", + payer.account.address, + quoteQuery.data?.approvalData, + ], + queryFn: () => { + if (!quoteQuery.data?.approvalData) { + return null; + } + return allowance({ + contract: getContract({ + client: props.client, + address: quoteQuery.data.swapDetails.fromToken.tokenAddress, + chain: getCachedChain(quoteQuery.data.swapDetails.fromToken.chainId), + }), + spender: quoteQuery.data.approvalData.spenderAddress, + owner: props.payer.account.address, + }); + }, + enabled: !!quoteQuery.data?.approvalData, + refetchOnMount: true, + }); + + const sourceTokenAmount = swapRequired + ? quoteQuery.data?.swapDetails.fromAmount + : tokenAmount; + + const isNotEnoughBalance = + !!sourceTokenAmount && + !!fromTokenBalanceQuery.data && + Number(fromTokenBalanceQuery.data.displayValue) < Number(sourceTokenAmount); + + const disableContinue = + (swapRequired && !quoteQuery.data) || + isNotEnoughBalance || + allowanceQuery.isLoading; + const switchChainRequired = + props.payer.wallet.getChain()?.id !== fromChain.id; + + const errorMsg = + !quoteQuery.isLoading && quoteQuery.error + ? getErrorMessage(quoteQuery.error) + : undefined; + + function showSwapFlow() { + if ( + (props.payOptions.mode === "direct_payment" || + props.payOptions.mode === "fund_wallet") && + !isNotEnoughBalance && + !swapRequired + ) { + // same currency, just direct transfer + setScreen({ + id: "transfer-flow", + }); + } else if ( + props.payOptions.mode === "transaction" && + !isNotEnoughBalance && + !swapRequired + ) { + if (payer.account.address !== receiverAddress) { + // needs transfer from another wallet before executing the transaction + setScreen({ + id: "transfer-flow", + }); + } else { + // has enough balance to just do the transaction directly + props.onDone(); + } + + return; + } + + if (!quoteQuery.data) { + return; + } + + setScreen({ + id: "swap-flow", + quote: quoteQuery.data, + approvalAmount: allowanceQuery.data ?? undefined, + }); + } + + function showFees() { + if (!quoteQuery.data) { + return; + } + + setIsOpen(true); + setDrawerScreen("fees"); + } + + return ( + + {isOpen && ( + <> + + setIsOpen(false)}> + {drawerScreen === "fees" && quoteQuery.data && ( +
+ + Fees + + + +
+ )} +
+ + )} + + {/* Quote info */} +
+ + {swapRequired && ( + + )} + +
+ + {/* Error message */} + {errorMsg && ( +
+ {errorMsg.data?.minimumAmountEth ? ( + + Minimum amount is{" "} + {formatNumber(Number(errorMsg.data.minimumAmountEth), 6)}{" "} + + + ) : ( + + {errorMsg.message || defaultMessage} + + )} +
+ )} + + {!errorMsg && isNotEnoughBalance && ( +
+ + Not enough funds. + + + Try a different wallet or token. + +
+ )} + + {/* Button */} + {errorMsg?.data?.minimumAmountEth ? ( + + ) : switchChainRequired && + !quoteQuery.isLoading && + !allowanceQuery.isLoading && + !isNotEnoughBalance && + !quoteQuery.error ? ( + { + await props.payer.wallet.switchChain(fromChain); + }} + /> + ) : ( + + )} +
+ ); +} diff --git a/packages/thirdweb/src/react/web/utils/errors.ts b/packages/thirdweb/src/react/web/utils/errors.ts new file mode 100644 index 00000000000..efeb88a6011 --- /dev/null +++ b/packages/thirdweb/src/react/web/utils/errors.ts @@ -0,0 +1,22 @@ +type ApiError = { + code: string; + message?: string; + data?: { + minimumAmountUSDCents?: string; + requestedAmountUSDCents?: string; + minimumAmountWei?: string; + minimumAmountEth?: string; + }; +}; + +export const defaultMessage = "Unable to get price quote"; +// biome-ignore lint/suspicious/noExplicitAny: +export function getErrorMessage(err: any): ApiError { + if (typeof err.error === "object" && err.error.code) { + return err.error; + } + return { + code: "UNABLE_TO_GET_PRICE_QUOTE", + message: defaultMessage, + }; +}