From 84571efaf80bc7b9054a375233beb8789326255d Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Mon, 28 Oct 2024 20:15:19 +1300 Subject: [PATCH] feat: redesign payment selection flow for Buy screen (#5176) --- .changeset/sweet-days-admire.md | 5 + .../src/app/connect/pay/commerce/page.tsx | 85 ++++++ .../src/app/connect/pay/page.tsx | 130 +-------- .../src/app/connect/pay/transactions/page.tsx | 141 ++++++++++ apps/playground-web/src/app/navLinks.ts | 24 +- .../src/components/pay/embed.tsx | 11 + .../src/components/pay/transaction-button.tsx | 83 +++--- apps/playground-web/src/lib/client.ts | 22 +- .../ConnectWallet/icons/GenericWalletIcon.tsx | 22 -- .../ConnectWallet/screens/Buy/BuyScreen.tsx | 183 +++---------- .../screens/Buy/WalletSelectorButton.tsx | 184 +++++++++---- .../ConnectWallet/screens/Buy/main/types.ts | 2 +- .../Buy/main/useEnabledPaymentMethods.ts | 4 - .../screens/Buy/swap/PayWithCrypto.tsx | 128 ++++----- .../Buy/swap/PaymentSelectionScreen.tsx | 251 ++++++++++++++++++ .../Buy/swap/WalletSwitcherDrawerContent.tsx | 83 ------ .../screens/formatTokenBalance.ts | 3 +- .../web/ui/components/token/TokenSymbol.tsx | 4 +- 18 files changed, 825 insertions(+), 540 deletions(-) create mode 100644 .changeset/sweet-days-admire.md create mode 100644 apps/playground-web/src/app/connect/pay/commerce/page.tsx create mode 100644 apps/playground-web/src/app/connect/pay/transactions/page.tsx delete mode 100644 packages/thirdweb/src/react/web/ui/ConnectWallet/icons/GenericWalletIcon.tsx create mode 100644 packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PaymentSelectionScreen.tsx delete mode 100644 packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/WalletSwitcherDrawerContent.tsx diff --git a/.changeset/sweet-days-admire.md b/.changeset/sweet-days-admire.md new file mode 100644 index 00000000000..7cba4edbf3b --- /dev/null +++ b/.changeset/sweet-days-admire.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Redesigned Pay payment selection flow diff --git a/apps/playground-web/src/app/connect/pay/commerce/page.tsx b/apps/playground-web/src/app/connect/pay/commerce/page.tsx new file mode 100644 index 00000000000..e15e55d3227 --- /dev/null +++ b/apps/playground-web/src/app/connect/pay/commerce/page.tsx @@ -0,0 +1,85 @@ +import { APIHeader } from "@/components/blocks/APIHeader"; +import { CodeExample } from "@/components/code/code-example"; +import { BuyMerchPreview } from "@/components/pay/direct-payment"; +import ThirdwebProvider from "@/components/thirdweb-provider"; +import { metadataBase } from "@/lib/constants"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + metadataBase, + title: "Integrate Fiat & Cross-Chain Crypto Payments | thirdweb Pay", + description: + "The easiest way for users to transact in your app. Onramp users in clicks and generate revenue for each user transaction. Integrate for free.", +}; + +export default function Page() { + return ( + +
+ + Let your users pay for any service with fiat or crypto on any + chain. + + } + docsLink="https://portal.thirdweb.com/connect/pay/get-started" + heroLink="/pay.png" + /> + +
+ +
+
+
+ ); +} + +function BuyMerch() { + return ( + <> +
+

+ Commerce +

+

+ Take paymets from Fiat or Crypto directly to your seller wallet. +
+ Get notified for every sale through webhooks, which lets you trigger + any action you want like shipping physical goods, activating services + or doing onchain actions. +

+
+ + } + code={`import { PayEmbed, getDefaultToken } from "thirdweb/react"; + import { base } from "thirdweb/chains"; + + function App() { + return ( + + ); + };`} + lang="tsx" + /> + + ); +} diff --git a/apps/playground-web/src/app/connect/pay/page.tsx b/apps/playground-web/src/app/connect/pay/page.tsx index fd273dfbfa3..9f741b02619 100644 --- a/apps/playground-web/src/app/connect/pay/page.tsx +++ b/apps/playground-web/src/app/connect/pay/page.tsx @@ -1,11 +1,9 @@ +import { APIHeader } from "@/components/blocks/APIHeader"; +import { CodeExample } from "@/components/code/code-example"; +import { StyledPayEmbedPreview } from "@/components/pay/embed"; import ThirdwebProvider from "@/components/thirdweb-provider"; import { metadataBase } from "@/lib/constants"; import type { Metadata } from "next"; -import { APIHeader } from "../../../components/blocks/APIHeader"; -import { CodeExample } from "../../../components/code/code-example"; -import { BuyMerchPreview } from "../../../components/pay/direct-payment"; -import { StyledPayEmbedPreview } from "../../../components/pay/embed"; -import { PayTransactionButtonPreview } from "../../../components/pay/transaction-button"; export const metadata: Metadata = { metadataBase, @@ -19,7 +17,7 @@ export default function Page() {
Onramp users with credit card & cross-chain crypto payments — @@ -33,18 +31,6 @@ export default function Page() {
- -
- -
- -
- -
- -
- -
); @@ -73,109 +59,19 @@ function StyledPayEmbed() { return ( - ); - };`} - lang="tsx" - /> - - ); -} - -function BuyMerch() { - return ( - <> -
-

- Commerce -

-

- Take paymets from Fiat or Crypto directly to your seller wallet. -
- Get notified for every sale through webhooks, which lets you trigger - any action you want like shipping physical goods, activating services - or doing onchain actions. -

-
- - } - code={`import { PayEmbed, getDefaultToken } from "thirdweb/react"; - import { base } from "thirdweb/chains"; - - function App() { - return ( - - ); - };`} - lang="tsx" - /> - - ); -} - -function BuyOnchainAsset() { - return ( - <> -
-

- Transactions -

-

- Let your users pay for onchain transactions with fiat or crypto on any - chain. -
- Amounts are calculated automatically from the transaction, and will - get executed after the user has obtained the necessary funds via - onramp or swap. -

-
- - } - code={`import { claimTo } from "thirdweb/extensions/erc1155"; - import { PayEmbed, useActiveAccount } from "thirdweb/react"; - - - function App() { - const account = useActiveAccount(); - const { data: nft } = useReadContract(getNFT, { - contract: nftContract, - tokenId: 0n, - }); - - - return ( - - ); + /> + ); };`} lang="tsx" /> diff --git a/apps/playground-web/src/app/connect/pay/transactions/page.tsx b/apps/playground-web/src/app/connect/pay/transactions/page.tsx new file mode 100644 index 00000000000..09e618e7b20 --- /dev/null +++ b/apps/playground-web/src/app/connect/pay/transactions/page.tsx @@ -0,0 +1,141 @@ +import { APIHeader } from "@/components/blocks/APIHeader"; +import { CodeExample } from "@/components/code/code-example"; +import { + PayTransactionButtonPreview, + PayTransactionPreview, +} from "@/components/pay/transaction-button"; +import ThirdwebProvider from "@/components/thirdweb-provider"; +import { metadataBase } from "@/lib/constants"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + metadataBase, + title: "Integrate Fiat & Cross-Chain Crypto Payments | thirdweb Pay", + description: + "The easiest way for users to transact in your app. Onramp users in clicks and generate revenue for each user transaction. Integrate for free.", +}; + +export default function Page() { + return ( + +
+ + Let your users pay for onchain transactions with fiat or crypto on + any chain. + + } + docsLink="https://portal.thirdweb.com/connect/pay/get-started" + heroLink="/pay.png" + /> + +
+ +
+ +
+ +
+ +
+
+
+ ); +} + +function BuyOnchainAsset() { + return ( + <> +
+

+ Transactions +

+

+ Let your users pay for onchain transactions with fiat or crypto on any + chain. +
+ Amounts are calculated automatically from the transaction, and will + get executed after the user has obtained the necessary funds via + onramp or swap. +

+
+ + } + code={`import { claimTo } from "thirdweb/extensions/erc1155"; + import { PayEmbed, useActiveAccount } from "thirdweb/react"; + + + function App() { + const account = useActiveAccount(); + const { data: nft } = useReadContract(getNFT, { + contract: nftContract, + tokenId: 0n, + }); + + + return ( + + ); + };`} + lang="tsx" + /> + + ); +} + +function NoFundsPopup() { + return ( + <> +
+

+ Automatic Onramp +

+

+ Any transaction with value will automatically trigger onramp to fund + the wallet if needed before executing the transaction. +

+
+ + } + code={`import { trasnfer } from "thirdweb/extensions/erc1155"; + import { PayEmbed, useActiveAccount } from "thirdweb/react"; + + + function App() { + const account = useActiveAccount(); + + return ( + { + if (!account) { throw new Error("No wallet connected"); } + return transfer({ + contract: usdcContract, + amount: "50", + to: account.address, + }); + }} + /> + ); + };`} + lang="tsx" + /> + + ); +} diff --git a/apps/playground-web/src/app/navLinks.ts b/apps/playground-web/src/app/navLinks.ts index 40d801973a6..d870b0e4707 100644 --- a/apps/playground-web/src/app/navLinks.ts +++ b/apps/playground-web/src/app/navLinks.ts @@ -55,18 +55,32 @@ export const navLinks: SidebarLink[] = [ }, ], }, - { - name: "Social", - href: "/connect/social", - }, { name: "Pay", - href: "/connect/pay", + expanded: true, + links: [ + { + name: "Top up", + href: "/connect/pay", + }, + { + name: "Commerce", + href: "/connect/pay/commerce", + }, + { + name: "Transactions", + href: "/connect/pay/transactions", + }, + ], }, { name: "Auth", href: "/connect/auth", }, + { + name: "Social", + href: "/connect/social", + }, { name: "Blockchain API", href: "/connect/blockchain-api", diff --git a/apps/playground-web/src/components/pay/embed.tsx b/apps/playground-web/src/components/pay/embed.tsx index 5d94400b6bf..d84bbb0172e 100644 --- a/apps/playground-web/src/components/pay/embed.tsx +++ b/apps/playground-web/src/components/pay/embed.tsx @@ -2,6 +2,7 @@ import { THIRDWEB_CLIENT } from "@/lib/client"; import { useTheme } from "next-themes"; +import { base } from "thirdweb/chains"; import { PayEmbed } from "thirdweb/react"; export function StyledPayEmbedPreview() { @@ -11,6 +12,16 @@ export function StyledPayEmbedPreview() { ); } diff --git a/apps/playground-web/src/components/pay/transaction-button.tsx b/apps/playground-web/src/components/pay/transaction-button.tsx index 8481acc4e41..abe5aedf5dc 100644 --- a/apps/playground-web/src/components/pay/transaction-button.tsx +++ b/apps/playground-web/src/components/pay/transaction-button.tsx @@ -28,7 +28,7 @@ const usdcContract = getContract({ client: THIRDWEB_CLIENT, }); -export function PayTransactionButtonPreview() { +export function PayTransactionPreview() { const account = useActiveAccount(); const { theme } = useTheme(); const { data: nft } = useReadContract(getNFT, { @@ -41,42 +41,51 @@ export function PayTransactionButtonPreview() {
{account && ( - <> - -
-

ERC20 Transfer (no metadata)

- { - if (!account) throw new Error("No active account"); - return transfer({ - contract: usdcContract, - amount: "50", - to: account?.address || "", - }); - }} - onError={(e) => { - console.error(e); - }} - payModal={{ - theme: theme === "light" ? "light" : "dark", - }} - > - Buy NFT - - + + )} + + ); +} + +export function PayTransactionButtonPreview() { + const account = useActiveAccount(); + const { theme } = useTheme(); + + return ( + <> + + {account && ( + { + if (!account) throw new Error("No active account"); + return transfer({ + contract: usdcContract, + amount: "50", + to: account?.address || "", + }); + }} + onError={(e) => { + console.error(e); + }} + payModal={{ + theme: theme === "light" ? "light" : "dark", + }} + > + Transfer funds + )} ); diff --git a/apps/playground-web/src/lib/client.ts b/apps/playground-web/src/lib/client.ts index b5886581c71..a5e484cf213 100644 --- a/apps/playground-web/src/lib/client.ts +++ b/apps/playground-web/src/lib/client.ts @@ -10,10 +10,30 @@ setThirdwebDomains({ pay: process.env.NEXT_PUBLIC_PAY_URL, }); +const isDev = + process.env.NODE_ENV === "development" || + process.env.NEXT_PUBLIC_RPC_URL?.endsWith(".thirdweb-dev.com"); + export const THIRDWEB_CLIENT = createThirdwebClient( process.env.THIRDWEB_SECRET_KEY - ? { secretKey: process.env.THIRDWEB_SECRET_KEY } + ? { + secretKey: process.env.THIRDWEB_SECRET_KEY, + config: { + storage: isDev + ? { + gatewayUrl: "https://gateway.pinata.cloud/ipfs/", + } + : undefined, + }, + } : { clientId: process.env.NEXT_PUBLIC_THIRDWEB_CLIENT_ID as string, + config: { + storage: isDev + ? { + gatewayUrl: "https://gateway.pinata.cloud/ipfs/", + } + : undefined, + }, }, ); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/GenericWalletIcon.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/GenericWalletIcon.tsx deleted file mode 100644 index fcd78e23589..00000000000 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/GenericWalletIcon.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { IconFC } from "./types.js"; - -/** - * @internal - */ -export const GenericWalletIcon: IconFC = (props) => { - return ( - - - - ); -}; 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 b8581eec007..b2c9f763231 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,7 +1,5 @@ -import { IdCardIcon } from "@radix-ui/react-icons"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; -import { trackPayEvent } from "../../../../../../analytics/track/pay.js"; import type { Chain } from "../../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../../client/client.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../../constants/addresses.js"; @@ -14,7 +12,6 @@ import type { Account } from "../../../../../../wallets/interfaces/wallet.js"; import type { WalletId } from "../../../../../../wallets/wallet-types.js"; import { type Theme, - iconSize, spacing, } from "../../../../../core/design-system/index.js"; import type { @@ -44,7 +41,6 @@ import { Text } from "../../../components/text.js"; import { TokenSymbol } from "../../../components/token/TokenSymbol.js"; import { ConnectButton } from "../../ConnectButton.js"; import { ChainButton, NetworkSelectorContent } from "../../NetworkSelector.js"; -import { CoinsIcon } from "../../icons/CoinsIcon.js"; import type { ConnectLocale } from "../../locale/types.js"; import { TokenSelector } from "../TokenSelector.js"; import { WalletSwitcherConnectionScreen } from "../WalletSwitcherConnectionScreen.js"; @@ -54,7 +50,6 @@ import { EstimatedTimeAndFees } from "./EstimatedTimeAndFees.js"; import { PayTokenIcon } from "./PayTokenIcon.js"; import { PayWithCreditCard } from "./PayWIthCreditCard.js"; import { TransactionModeScreen } from "./TransactionModeScreen.js"; -import { WalletSelectorButton } from "./WalletSelectorButton.js"; import { CurrencySelection } from "./fiat/CurrencySelection.js"; import { FiatFlow } from "./fiat/FiatFlow.js"; import type { CurrencyMeta } from "./fiat/currencies.js"; @@ -71,10 +66,10 @@ import { import { openOnrampPopup } from "./openOnRamppopup.js"; import { BuyTokenInput } from "./swap/BuyTokenInput.js"; import { FiatFees, SwapFees } from "./swap/Fees.js"; -import { PayWithCrypto } from "./swap/PayWithCrypto.js"; +import { PayWithCryptoQuoteInfo } from "./swap/PayWithCrypto.js"; +import { PaymentSelectionScreen } from "./swap/PaymentSelectionScreen.js"; import { SwapFlow } from "./swap/SwapFlow.js"; import { TransferFlow } from "./swap/TransferFlow.js"; -import { WalletSwitcherDrawerContent } from "./swap/WalletSwitcherDrawerContent.js"; import { addPendingTx } from "./swap/pendingSwapTx.js"; import { type SupportedChainAndTokens, @@ -222,7 +217,6 @@ function BuyScreenContent(props: BuyScreenContentProps) { }); const payDisabled = - enabledPaymentMethods.showPaymentSelection === false && enabledPaymentMethods.buyWithCryptoEnabled === false && enabledPaymentMethods.buyWithFiatEnabled === false; @@ -515,7 +509,6 @@ function BuyScreenContent(props: BuyScreenContentProps) { )} {(screen.id === "select-payment-method" || - screen.id === "select-wallet" || screen.id === "buy-with-crypto" || screen.id === "buy-with-fiat") && payer && ( @@ -527,44 +520,43 @@ function BuyScreenContent(props: BuyScreenContentProps) { client={client} onBack={() => { if ( - enabledPaymentMethods.showPaymentSelection && - (screen.id === "select-wallet" || - screen.id === "buy-with-fiat") + enabledPaymentMethods.buyWithCryptoEnabled && + screen.id === "buy-with-fiat" ) { setScreen({ id: "select-payment-method" }); } else if (screen.id === "buy-with-crypto") { - setScreen({ id: "select-wallet" }); + setScreen({ id: "select-payment-method" }); } else { setScreen({ id: "main" }); } }} > {screen.id === "select-payment-method" && ( - setScreen({ id })} - /> - )} - - {screen.id === "select-wallet" && ( - { - const chain = w.getChain(); + payWithFiatEnabled={props.payOptions.buyWithFiat !== false} + toChain={toChain} + toToken={toToken} + tokenAmount={tokenAmount} + onSelect={(w, token, chain) => { const account = w.getAccount(); - if (chain && account) { + if (account) { setPayer({ account, chain, wallet: w, }); + setFromToken(token); + setFromChain(chain); setScreen({ id: "buy-with-crypto" }); } }} + onSelectFiat={() => { + setScreen({ id: "buy-with-fiat" }); + }} showAllWallets={!!props.connectOptions?.showAllWallets} wallets={props.connectOptions?.wallets} onBack={() => { @@ -574,11 +566,10 @@ function BuyScreenContent(props: BuyScreenContentProps) { setScreen({ id: "connect-payer-wallet", backScreen: { - id: "select-wallet", + id: "select-payment-method", }, }); }} - selectedAddress={payer.account.address} /> )} @@ -736,8 +727,7 @@ function MainScreen(props: { enabledPaymentMethods, } = props; - const { showPaymentSelection, buyWithCryptoEnabled, buyWithFiatEnabled } = - enabledPaymentMethods; + const { buyWithCryptoEnabled, buyWithFiatEnabled } = enabledPaymentMethods; const disableContinue = !tokenAmount; switch (payOptions.mode) { @@ -755,15 +745,10 @@ function MainScreen(props: { setFromChain(toChain); setFromToken(toToken); setToToken(toToken); - if (showPaymentSelection) { - props.setScreen({ id: "select-payment-method" }); - } else if (buyWithCryptoEnabled) { - props.setScreen({ id: "select-wallet" }); - } else if (buyWithFiatEnabled) { + if (buyWithFiatEnabled && !buyWithCryptoEnabled) { props.setScreen({ id: "buy-with-fiat" }); } else { - // default to buy with crypto with connected wallet if chain not supported by pay - props.setScreen({ id: "select-wallet" }); + props.setScreen({ id: "select-payment-method" }); } }} /> @@ -783,15 +768,10 @@ function MainScreen(props: { setFromChain(toChain); setFromToken(toToken); setToToken(toToken); - if (showPaymentSelection) { - props.setScreen({ id: "select-payment-method" }); - } else if (buyWithCryptoEnabled) { - props.setScreen({ id: "buy-with-crypto" }); - } else if (buyWithFiatEnabled) { + if (buyWithFiatEnabled && !buyWithCryptoEnabled) { props.setScreen({ id: "buy-with-fiat" }); } else { - // default to buy with crypto with connected wallet if chain not supported by pay - props.setScreen({ id: "select-wallet" }); + props.setScreen({ id: "select-payment-method" }); } }} /> @@ -846,14 +826,10 @@ function MainScreen(props: { disabled={disableContinue} data-disabled={disableContinue} onClick={() => { - if (showPaymentSelection) { - props.setScreen({ id: "select-payment-method" }); - } else if (buyWithCryptoEnabled) { - props.setScreen({ id: "buy-with-crypto" }); - } else if (buyWithFiatEnabled) { + if (buyWithFiatEnabled && !buyWithCryptoEnabled) { props.setScreen({ id: "buy-with-fiat" }); } else { - console.error("No payment method enabled"); + props.setScreen({ id: "select-payment-method" }); } }} > @@ -909,87 +885,6 @@ function TokenSelectedLayout(props: { ); } -function PaymentMethodSelection(props: { - client: ThirdwebClient; - walletAddress: string; - walletType: string; - setScreen: (screenId: "select-wallet" | "buy-with-fiat") => void; - mode?: "transaction" | "direct_payment" | "fund_wallet"; -}) { - return ( - - {/* Credit Card */} - - - - {/* Crypto */} - - - - ); -} - function SwapScreenContent(props: { setScreen: (screen: SelectedScreen) => void; tokenAmount: string; @@ -1020,7 +915,6 @@ function SwapScreenContent(props: { toToken, fromChain, fromToken, - showFromTokenSelector, payOptions, disableTokenSelection, } = props; @@ -1194,22 +1088,8 @@ function SwapScreenContent(props: { {/* Quote info */}
- { - setScreen({ id: "select-wallet" }); - }} - address={props.payer.account.address} - walletId={props.payer.wallet.id} - containerStyle={{ - borderBottomRightRadius: 0, - borderBottomLeftRadius: 0, - }} - /> - - { + // for source tokens, data is not provided, so we include all of them + if ( + t.buyWithCryptoEnabled === undefined && + t.buyWithFiatEnabled === undefined + ) { + return true; + } // it token supports both - include it if (t.buyWithCryptoEnabled && t.buyWithFiatEnabled) { return true; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/WalletSelectorButton.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/WalletSelectorButton.tsx index d6af3cece62..bcaffc5b770 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/WalletSelectorButton.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/WalletSelectorButton.tsx @@ -1,83 +1,137 @@ -import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons"; +import styled from "@emotion/styled"; +import { useState } from "react"; +import type { Chain } from "../../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../../client/client.js"; import { shortenAddress } from "../../../../../../utils/address.js"; +import type { Wallet } from "../../../../../../wallets/interfaces/wallet.js"; import type { WalletId } from "../../../../../../wallets/wallet-types.js"; import { useCustomTheme } from "../../../../../core/design-system/CustomThemeProvider.js"; import { + type fontSize, iconSize, radius, spacing, } from "../../../../../core/design-system/index.js"; -import { useConnectedWallets } from "../../../../../core/hooks/wallets/useConnectedWallets.js"; +import { useChainName } from "../../../../../core/hooks/others/useChainQuery.js"; +import type { TokenInfo } from "../../../../../core/utils/defaultTokens.js"; import { useEnsAvatar, useEnsName } from "../../../../../core/utils/wallet.js"; import { Img } from "../../../components/Img.js"; +import { TokenIcon } from "../../../components/TokenIcon.js"; import { WalletImage } from "../../../components/WalletImage.js"; import { Container } from "../../../components/basic.js"; import { Button } from "../../../components/buttons.js"; import { Text } from "../../../components/text.js"; +import { Blobbie } from "../../Blobbie.js"; +import { formatTokenBalance } from "../formatTokenBalance.js"; +import type { TokenBalance } from "./swap/PaymentSelectionScreen.js"; -export function WalletSelectorButton(props: { - address: string; - walletId: WalletId | undefined; - onClick: () => void; +export function WalletRowWithBalances(props: { client: ThirdwebClient; - containerStyle?: React.CSSProperties; - disableChevron?: boolean; - disabled?: boolean; - checked?: boolean; + address: string; + wallet: Wallet; + balances: TokenBalance[]; + onClick: (wallet: Wallet, token: TokenInfo, chain: Chain) => void; }) { const theme = useCustomTheme(); + const [showAll, setShowAll] = useState(false); + const maxDisplayedBalances = 3; + const displayedBalances = showAll + ? props.balances + : props.balances.slice(0, maxDisplayedBalances); + return ( - + ); } +function TokenBalanceRow(props: { + client: ThirdwebClient; + tokenBalance: TokenBalance; + wallet: Wallet; + onClick: (token: TokenInfo, wallet: Wallet) => void; +}) { + const { tokenBalance, wallet, onClick, client } = props; + const chainInfo = useChainName(tokenBalance.chain); + return ( + onClick(tokenBalance.token, wallet)} + variant="secondary" + > + + + + {tokenBalance.token.symbol} + + {chainInfo && {chainInfo.name}} + +
+ + + {formatTokenBalance(tokenBalance.balance, true, 3)} + + + + ); +} + export function WalletRow(props: { client: ThirdwebClient; address: string; + iconSize?: keyof typeof iconSize; + textSize?: keyof typeof fontSize; walletId?: WalletId; }) { const { client, address } = props; - const connectedWallets = useConnectedWallets(); - const wallet = connectedWallets.find( - (x) => x.getAccount()?.address === props.address, - ); - const walletId = props.walletId || wallet?.id; + const walletId = props.walletId; + const theme = useCustomTheme(); const ensNameQuery = useEnsName({ client, address, @@ -92,20 +146,54 @@ export function WalletRow(props: { {ensAvatarQuery.data ? ( ) : walletId ? ( - - ) : null} + + ) : ( + + + + )} - + {addressOrENS || shortenAddress(props.address)} ); } + +const StyledButton = /* @__PURE__ */ styled(Button)((_) => { + const theme = useCustomTheme(); + return { + background: theme.colors.tertiaryBg, + justifyContent: "flex-start", + flexDirection: "row", + padding: spacing.sm, + gap: spacing.sm, + "&:hover": { + background: theme.colors.secondaryButtonBg, + transform: "scale(1.01)", + }, + transition: "background 200ms ease, transform 150ms ease", + }; +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts index 582a922bb1c..8ab5ec98a14 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/types.ts @@ -13,7 +13,7 @@ export type TransactionCostAndData = { export type SelectedScreen = | { - id: "main" | "select-payment-method" | "buy-with-fiat" | "select-wallet"; + id: "main" | "select-payment-method" | "buy-with-fiat"; } | { id: "buy-with-crypto"; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useEnabledPaymentMethods.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useEnabledPaymentMethods.ts index f42767f4e92..c9ff63d1621 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useEnabledPaymentMethods.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/main/useEnabledPaymentMethods.ts @@ -8,7 +8,6 @@ import type { SupportedChainAndTokens } from "../swap/useSwapSupportedChains.js" // change the current method if it should be disabled // return whether the payment selection should be shown or not ( if only one payment method is enabled, don't show the selection ) export type PaymentMethods = { - showPaymentSelection: boolean; buyWithFiatEnabled: boolean; buyWithCryptoEnabled: boolean; }; @@ -59,11 +58,8 @@ export function useEnabledPaymentMethods(options: { const buyWithFiatEnabled = payOptions.buyWithFiat !== false && fiat; const buyWithCryptoEnabled = payOptions.buyWithCrypto !== false && swap; - const showPaymentSelection = buyWithFiatEnabled && buyWithCryptoEnabled; - return { buyWithFiatEnabled, buyWithCryptoEnabled, - showPaymentSelection, }; } diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PayWithCrypto.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PayWithCrypto.tsx index 85071a05e34..b15746c32ba 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PayWithCrypto.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PayWithCrypto.tsx @@ -1,11 +1,10 @@ -import { ChevronDownIcon } from "@radix-ui/react-icons"; import type { Chain } from "../../../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../../../client/client.js"; import { formatNumber } from "../../../../../../../utils/formatNumber.js"; import type { Account } from "../../../../../../../wallets/interfaces/wallet.js"; +import { useCustomTheme } from "../../../../../../core/design-system/CustomThemeProvider.js"; import { fontSize, - iconSize, radius, spacing, } from "../../../../../../core/design-system/index.js"; @@ -14,13 +13,12 @@ import { useWalletBalance } from "../../../../../../core/hooks/others/useWalletB import type { TokenInfo } from "../../../../../../core/utils/defaultTokens.js"; import { Skeleton } from "../../../../components/Skeleton.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 { GenericWalletIcon } from "../../../icons/GenericWalletIcon.js"; import { formatTokenBalance } from "../../formatTokenBalance.js"; import { type NativeToken, isNativeToken } from "../../nativeToken.js"; import { PayTokenIcon } from "../PayTokenIcon.js"; +import { WalletRow } from "../WalletSelectorButton.js"; /** * Shows an amount "value" and renders the selected token and chain @@ -28,9 +26,8 @@ import { PayTokenIcon } from "../PayTokenIcon.js"; * It also renders the balance of active wallet for the selected token in selected chain * @internal */ -export function PayWithCrypto(props: { +export function PayWithCryptoQuoteInfo(props: { value: string; - onSelectToken: () => void; chain: Chain; token: TokenInfo | NativeToken; isLoading: boolean; @@ -39,8 +36,8 @@ export function PayWithCrypto(props: { payerAccount: Account; swapRequired: boolean; }) { + const theme = useCustomTheme(); const { name } = useChainName(props.chain); - const balanceQuery = useWalletBalance({ address: props.payerAccount.address, chain: props.chain, @@ -51,36 +48,56 @@ export function PayWithCrypto(props: { return ( - {/* Left */} - - - {/* Right */} -
- {props.isLoading ? ( - - ) : ( - - {formatNumber(Number(props.value), 6) || ""} - - )} - - - - {balanceQuery.data ? ( - - {formatTokenBalance(balanceQuery.data, false)} - - ) : ( - - )} - -
+
); } diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PaymentSelectionScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PaymentSelectionScreen.tsx new file mode 100644 index 00000000000..247a8d7ab0c --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/PaymentSelectionScreen.tsx @@ -0,0 +1,251 @@ +import { IdCardIcon } from "@radix-ui/react-icons"; +import { useQuery } from "@tanstack/react-query"; +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 type { Wallet } from "../../../../../../../wallets/interfaces/wallet.js"; +import { + type GetWalletBalanceResult, + getWalletBalance, +} from "../../../../../../../wallets/utils/getWalletBalance.js"; +import type { WalletId } from "../../../../../../../wallets/wallet-types.js"; +import { useCustomTheme } from "../../../../../../core/design-system/CustomThemeProvider.js"; +import { + iconSize, + radius, + spacing, +} from "../../../../../../core/design-system/index.js"; +import type { PayUIOptions } from "../../../../../../core/hooks/connection/ConnectButtonProps.js"; +import { useChainMetadata } from "../../../../../../core/hooks/others/useChainQuery.js"; +import { useActiveAccount } from "../../../../../../core/hooks/wallets/useActiveAccount.js"; +import { useConnectedWallets } from "../../../../../../core/hooks/wallets/useConnectedWallets.js"; +import type { + SupportedTokens, + TokenInfo, +} from "../../../../../../core/utils/defaultTokens.js"; +import { LoadingScreen } from "../../../../../wallets/shared/LoadingScreen.js"; +import { Spacer } from "../../../../components/Spacer.js"; +import { Container } from "../../../../components/basic.js"; +import { Button } from "../../../../components/buttons.js"; +import { Text } from "../../../../components/text.js"; +import { OutlineWalletIcon } from "../../../icons/OutlineWalletIcon.js"; +import { type ERC20OrNativeToken, isNativeToken } from "../../nativeToken.js"; +import { WalletRowWithBalances } from "../WalletSelectorButton.js"; + +export type TokenBalance = { + balance: GetWalletBalanceResult; + chain: Chain; + token: TokenInfo; +}; + +export function PaymentSelectionScreen(props: { + client: ThirdwebClient; + mode: PayUIOptions["mode"]; + showAllWallets: boolean; + sourceSupportedTokens: SupportedTokens | undefined; + toChain: Chain; + toToken: ERC20OrNativeToken; + tokenAmount: string; + wallets: Wallet[] | undefined; + onSelect: (wallet: Wallet, token: TokenInfo, chain: Chain) => void; + onSelectFiat: () => void; + onBack: () => void; + onConnect: () => void; + hiddenWallets?: WalletId[]; + payWithFiatEnabled: boolean; +}) { + const theme = useCustomTheme(); + const connectedWallets = useConnectedWallets(); + + // if all wallets are connected and showAll wallets is disabled, hide the connect button + const hideConnectButton = + !props.showAllWallets && + props.wallets?.every((w) => connectedWallets.includes(w)); + + const chainInfo = useChainMetadata(props.toChain); + const activeAccount = useActiveAccount(); + + const walletsAndBalances = useQuery({ + queryKey: [ + "wallets-and-balances", + connectedWallets.map((w) => w.getAccount()?.address), + props.sourceSupportedTokens, + props.toChain.id, + props.toToken, + props.tokenAmount, + props.mode, + activeAccount?.address, + ], + queryFn: async () => { + // in parallel, get the balances of all the wallets on each of the sourceSupportedTokens + const walletBalanceMap = new Map(); + + const balancePromises = connectedWallets.flatMap((wallet) => { + const account = wallet.getAccount(); + if (!account) return []; + walletBalanceMap.set(wallet, []); + + // inject the destination token too since it can be used as well to pay/transfer + const toToken = isNativeToken(props.toToken) + ? { + address: NATIVE_TOKEN_ADDRESS, + name: chainInfo.data?.nativeCurrency.name || "", + symbol: chainInfo.data?.nativeCurrency.symbol || "", + icon: chainInfo.data?.icon?.url, + } + : props.toToken; + + const tokens = { + ...props.sourceSupportedTokens, + [props.toChain.id]: [ + toToken, + ...(props.sourceSupportedTokens?.[props.toChain.id] || []), + ], + }; + + return Object.entries(tokens).flatMap(([chainId, tokens]) => { + return tokens.map(async (token) => { + try { + const chain = getCachedChain(Number(chainId)); + const balance = await getWalletBalance({ + address: account.address, + chain, + tokenAddress: isNativeToken(token) ? undefined : token.address, + client: props.client, + }); + + // show the token if: + // - its not the destination token and balance is greater than 0 + // - its the destination token and balance is greater than the token amount AND we the account is not the default account in fund_wallet mode + const shouldInclude = + token.address === toToken.address && + chain.id === props.toChain.id + ? props.mode === "fund_wallet" && + account.address === activeAccount?.address + ? false + : Number(balance.displayValue) > Number(props.tokenAmount) + : balance.value > 0n; + + if (shouldInclude) { + const existingBalances = walletBalanceMap.get(wallet) || []; + existingBalances.push({ balance, chain, token }); + existingBalances.sort((a, b) => { + if ( + a.chain.id === props.toChain.id && + a.token.address === toToken.address + ) + return -1; + if ( + b.chain.id === props.toChain.id && + b.token.address === toToken.address + ) + return 1; + if (a.chain.id === props.toChain.id) return -1; + if (b.chain.id === props.toChain.id) return 1; + return a.chain.id > b.chain.id ? 1 : -1; + }); + } + } catch (error) { + console.error( + `Failed to fetch balance for wallet ${wallet.id} on chain ${chainId} for token ${token.symbol}:`, + error, + ); + } + }); + }); + }); + + await Promise.all(balancePromises); + return walletBalanceMap; + }, + enabled: !!props.sourceSupportedTokens && !!chainInfo.data, + }); + + if (walletsAndBalances.isLoading || !walletsAndBalances.data) { + return ; + } + + return ( + + + {Array.from(walletsAndBalances.data?.entries() || []) + .filter(([w]) => !props.hiddenWallets?.includes(w.id)) + .map(([w, balances]) => { + const address = w.getAccount()?.address; + if (!address) return null; + return ( + + ); + })} + {!hideConnectButton && ( + + )} + {props.payWithFiatEnabled && ( + + )} + + + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/WalletSwitcherDrawerContent.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/WalletSwitcherDrawerContent.tsx deleted file mode 100644 index ddfbe78a902..00000000000 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/swap/WalletSwitcherDrawerContent.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { PlusIcon } from "@radix-ui/react-icons"; -import type { ThirdwebClient } from "../../../../../../../client/client.js"; -import type { Wallet } from "../../../../../../../wallets/interfaces/wallet.js"; -import type { WalletId } from "../../../../../../../wallets/wallet-types.js"; -import { useCustomTheme } from "../../../../../../core/design-system/CustomThemeProvider.js"; -import { - iconSize, - radius, - spacing, -} from "../../../../../../core/design-system/index.js"; -import { useConnectedWallets } from "../../../../../../core/hooks/wallets/useConnectedWallets.js"; -import { Spacer } from "../../../../components/Spacer.js"; -import { Container } from "../../../../components/basic.js"; -import { Button } from "../../../../components/buttons.js"; -import { Text } from "../../../../components/text.js"; -import { WalletSelectorButton } from "../WalletSelectorButton.js"; - -export function WalletSwitcherDrawerContent(props: { - client: ThirdwebClient; - showAllWallets: boolean; - wallets: Wallet[] | undefined; - onSelect: (wallet: Wallet) => void; - onBack: () => void; - onConnect: () => void; - selectedAddress: string; - hiddenWallets?: WalletId[]; -}) { - const theme = useCustomTheme(); - const connectedWallets = useConnectedWallets(); - - // if all wallets are connected and showAll wallets is disabled, hide the connect button - const hideConnectButton = - !props.showAllWallets && - props.wallets?.every((w) => connectedWallets.includes(w)); - - return ( - - - {connectedWallets - .filter((w) => !props.hiddenWallets?.includes(w.id)) - .map((w) => { - const address = w.getAccount()?.address; - return ( - { - props.onSelect(w); - props.onBack(); - }} - disableChevron - checked={false} - /> - ); - })} - {!hideConnectButton && ( - - )} - - - - ); -} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.ts index 7629c67e1be..c29fcbe9ecc 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.ts +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.ts @@ -13,9 +13,10 @@ export function formatTokenBalance( displayValue: string; }, showSymbol = true, + decimals = 5, ) { return ( - formatNumber(Number(balanceData.displayValue), 5) + + formatNumber(Number(balanceData.displayValue), decimals) + (showSymbol ? ` ${balanceData.symbol}` : "") ); } diff --git a/packages/thirdweb/src/react/web/ui/components/token/TokenSymbol.tsx b/packages/thirdweb/src/react/web/ui/components/token/TokenSymbol.tsx index 26e06d2dd8a..da419ec87e0 100644 --- a/packages/thirdweb/src/react/web/ui/components/token/TokenSymbol.tsx +++ b/packages/thirdweb/src/react/web/ui/components/token/TokenSymbol.tsx @@ -15,7 +15,7 @@ import { Text } from "../text.js"; export function TokenSymbol(props: { token: ERC20OrNativeToken; chain: Chain; - size: "sm" | "md" | "lg"; + size: "xs" | "sm" | "md" | "lg"; color?: keyof Theme["colors"]; inline?: boolean; }) { @@ -43,7 +43,7 @@ export function TokenSymbol(props: { function NativeTokenSymbol(props: { chain: Chain; - size: "sm" | "md" | "lg"; + size: "xs" | "sm" | "md" | "lg"; color?: keyof Theme["colors"]; inline?: boolean; }) {