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