From 50c1a192d308c2dac8aeb12d843b6f57801cc7e3 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Sat, 13 Sep 2025 03:29:34 +0530 Subject: [PATCH 01/36] temp --- packages/thirdweb/package.json | 1 + packages/thirdweb/src/bridge/types/Chain.ts | 2 + packages/thirdweb/src/pay/convert/type.ts | 46 +- .../src/react/core/design-system/index.ts | 3 +- .../src/react/web/ui/Bridge/StepRunner.tsx | 2 +- .../web/ui/Bridge/UnsupportedTokenScreen.tsx | 4 +- .../web/ui/Bridge/swap-widget/SearchInput.tsx | 41 ++ .../Bridge/swap-widget/SelectChainButton.tsx | 47 ++ .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 280 +++++++++ .../Bridge/swap-widget/select-buy-token.tsx | 334 +++++++++++ .../ui/Bridge/swap-widget/select-chain.tsx | 165 ++++++ .../Bridge/swap-widget/select-sell-token.tsx | 496 ++++++++++++++++ .../web/ui/Bridge/swap-widget/swap-ui.tsx | 541 ++++++++++++++++++ .../react/web/ui/Bridge/swap-widget/types.ts | 141 +++++ .../Bridge/swap-widget/use-bridge-chains.ts | 12 + .../web/ui/Bridge/swap-widget/use-tokens.ts | 115 ++++ .../react/web/ui/Bridge/swap-widget/utils.ts | 3 + .../ConnectWallet/icons/ArrowUpDownIcon.tsx | 23 + .../react/web/ui/components/DynamicHeight.tsx | 2 +- .../src/react/web/ui/components/Img.tsx | 38 +- .../src/react/web/ui/components/Skeleton.tsx | 2 + .../src/react/web/ui/components/Spinner.tsx | 2 + .../src/react/web/ui/components/basic.tsx | 5 +- .../src/react/web/ui/components/buttons.tsx | 19 +- .../react/web/ui/components/formElements.tsx | 3 +- .../src/react/web/ui/components/text.tsx | 4 +- .../Bridge/Swap/SelectBuyToken.stories.tsx | 53 ++ .../Bridge/Swap/SelectChain.stories.tsx | 51 ++ .../Bridge/Swap/SelectSellToken.stories.tsx | 78 +++ .../Bridge/Swap/SwapWidget.stories.tsx | 23 + .../src/stories/BuyWidget.stories.tsx | 34 ++ 31 files changed, 2539 insertions(+), 31 deletions(-) create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SearchInput.tsx create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SelectChainButton.tsx create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-buy-token.tsx create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts create mode 100644 packages/thirdweb/src/react/web/ui/ConnectWallet/icons/ArrowUpDownIcon.tsx create mode 100644 packages/thirdweb/src/stories/Bridge/Swap/SelectBuyToken.stories.tsx create mode 100644 packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx create mode 100644 packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx create mode 100644 packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx create mode 100644 packages/thirdweb/src/stories/BuyWidget.stories.tsx diff --git a/packages/thirdweb/package.json b/packages/thirdweb/package.json index dba4440ab33..2a060c6007c 100644 --- a/packages/thirdweb/package.json +++ b/packages/thirdweb/package.json @@ -341,6 +341,7 @@ "test:ui": "NODE_OPTIONS=--max-old-space-size=8192 vitest dev -c ./test/vitest.config.ts --coverage --ui", "test:watch": "vitest -c ./test/vitest.config.ts dev", "typedoc": "node scripts/typedoc.mjs && node scripts/parse.mjs", + "typecheck": "tsc --project ./tsconfig.build.json --module nodenext --moduleResolution nodenext --noEmit", "update-version": "node scripts/version.mjs" }, "sideEffects": false, diff --git a/packages/thirdweb/src/bridge/types/Chain.ts b/packages/thirdweb/src/bridge/types/Chain.ts index a3208126f8b..6e013f1e4bb 100644 --- a/packages/thirdweb/src/bridge/types/Chain.ts +++ b/packages/thirdweb/src/bridge/types/Chain.ts @@ -38,3 +38,5 @@ export interface Chain { decimals: number; }; } + +export type BridgeChain = Chain; diff --git a/packages/thirdweb/src/pay/convert/type.ts b/packages/thirdweb/src/pay/convert/type.ts index 62dd76cc0e0..7040deaa35c 100644 --- a/packages/thirdweb/src/pay/convert/type.ts +++ b/packages/thirdweb/src/pay/convert/type.ts @@ -27,22 +27,34 @@ const CURRENCIES = [ export type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {}); export function getFiatSymbol(showBalanceInFiat: SupportedFiatCurrency) { - switch (showBalanceInFiat) { - case "USD": - return "$"; - case "CAD": - return "$"; - case "GBP": - return "£"; - case "EUR": - return "€"; - case "JPY": - return "¥"; - case "AUD": - return "$"; - case "NZD": - return "$"; - default: - return "$"; + if (currencySymbol[showBalanceInFiat]) { + return currencySymbol[showBalanceInFiat]; } + return "$"; } + +const currencySymbol: Record = { + USD: "$", + EUR: "€", + GBP: "£", + JPY: "¥", + KRW: "₩", + CNY: "¥", + INR: "₹", + NOK: "kr", + SEK: "kr", + CHF: "CHF", + AUD: "$", + CAD: "$", + NZD: "$", + MXN: "$", + BRL: "R$", + CLP: "$", + CZK: "Kč", + DKK: "kr", + HKD: "$", + HUF: "Ft", + IDR: "Rp", + ILS: "₪", + ISK: "kr", +}; diff --git a/packages/thirdweb/src/react/core/design-system/index.ts b/packages/thirdweb/src/react/core/design-system/index.ts index a0484032317..8854e7ce51e 100644 --- a/packages/thirdweb/src/react/core/design-system/index.ts +++ b/packages/thirdweb/src/react/core/design-system/index.ts @@ -136,7 +136,7 @@ function createThemeObj(type: "dark" | "light", colors: ThemeColors): Theme { selectedTextColor: colors.base1, separatorLine: colors.base4, - skeletonBg: colors.base3, + skeletonBg: colors.base4, success: colors.success, tertiaryBg: colors.base2, @@ -191,6 +191,7 @@ export const radius = { xl: "20px", xs: "4px", xxl: "32px", + full: "9999px", }; export const iconSize = { diff --git a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx index c0c5266271a..de077639b5e 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx @@ -374,7 +374,7 @@ export function StepRunner({ - + Keep this window open until all
transactions are complete.
diff --git a/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx b/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx index 260f4a171a2..ada040f9eab 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/UnsupportedTokenScreen.tsx @@ -64,7 +64,7 @@ export function UnsupportedTokenScreen(props: UnsupportedTokenScreenProps) { size="sm" style={{ lineHeight: 1.5, maxWidth: "280px" }} > - The Universal Bridge does not support testnets at this time. + Bridge does not support testnets at this time.
); @@ -94,7 +94,7 @@ export function UnsupportedTokenScreen(props: UnsupportedTokenScreenProps) { size="sm" style={{ lineHeight: 1.5, maxWidth: "280px" }} > - This token or chain is not supported by the Universal Bridge. + This token or chain is not supported by the Bridge ); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SearchInput.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SearchInput.tsx new file mode 100644 index 00000000000..90526c527f1 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SearchInput.tsx @@ -0,0 +1,41 @@ +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { iconSize, spacing } from "../../../../core/design-system/index.js"; +import { Container } from "../../components/basic.js"; +import { Input } from "../../components/formElements.js"; + +export function SearchInput(props: { + value: string; + onChange: (value: string) => void; + placeholder: string; +}) { + return ( +
+ + + + + props.onChange(e.target.value)} + /> +
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SelectChainButton.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SelectChainButton.tsx new file mode 100644 index 00000000000..c7f40646aa4 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SelectChainButton.tsx @@ -0,0 +1,47 @@ +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { + fontSize, + iconSize, + spacing, +} from "../../../../core/design-system/index.js"; +import { Button } from "../../components/buttons.js"; +import { Img } from "../../components/Img.js"; +import { cleanedChainName } from "./utils.js"; + +export function SelectChainButton(props: { + selectedChain: BridgeChain; + client: ThirdwebClient; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx new file mode 100644 index 00000000000..936583b12de --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Buy, Sell } from "../../../../../bridge/index.js"; +import type { TokenWithPrices } from "../../../../../bridge/types/Token.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js"; +import { toTokens } from "../../../../../utils/units.js"; +import { CustomThemeProvider } from "../../../../core/design-system/CustomThemeProvider.js"; +import type { Theme } from "../../../../core/design-system/index.js"; +import type { + BridgePrepareRequest, + BridgePrepareResult, +} from "../../../../core/hooks/useBridgePrepare.js"; +import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccount.js"; +import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js"; +import { useActiveWalletChain } from "../../../../core/hooks/wallets/useActiveWalletChain.js"; +import { webWindowAdapter } from "../../../adapters/WindowAdapter.js"; +import { useConnectLocale } from "../../ConnectWallet/locale/getConnectLocale.js"; +import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js"; +import { DynamicHeight } from "../../components/DynamicHeight.js"; +import { Spinner } from "../../components/Spinner.js"; +import type { LocaleId } from "../../types.js"; +import { PaymentDetails } from "../payment-details/PaymentDetails.js"; +import { QuoteLoader } from "../QuoteLoader.js"; +import { StepRunner } from "../StepRunner.js"; +import { SwapUI } from "./swap-ui.js"; +import type { SwapWidgetConnectOptions } from "./types.js"; +import { useBridgeChains } from "./use-bridge-chains.js"; + +type SwapWidgetProps = { + client: ThirdwebClient; + theme?: "light" | "dark" | Theme; + className?: string; + locale?: LocaleId; + currency?: SupportedFiatCurrency; + style?: React.CSSProperties; + showThirdwebBranding?: boolean; + onSuccess?: () => void; + onError?: (error: Error) => void; + onCancel?: () => void; + connectOptions?: SwapWidgetConnectOptions; +}; + +export function SwapWidget(props: SwapWidgetProps) { + return ( + + + + ); +} + +export function SwapWidgetContainer(props: { + theme: SwapWidgetProps["theme"]; + className: string | undefined; + style?: React.CSSProperties | undefined; + children: React.ReactNode; +}) { + return ( + + + {props.children} + + + ); +} + +type SwapWidgetScreen = + | { id: "1:swap-ui" } + | { id: "2:loading-quote"; quote: Buy.quote.Result | Sell.quote.Result } + | { + id: "3:preview"; + preparedQuote: BridgePrepareResult; + request: BridgePrepareRequest; + quote: Buy.quote.Result | Sell.quote.Result; + } + | { + id: "4:execute"; + request: BridgePrepareRequest; + quote: Buy.quote.Result | Sell.quote.Result; + preparedQuote: BridgePrepareResult; + }; + +function SwapWidgetContent(props: SwapWidgetProps) { + const [screen, setScreen] = useState({ id: "1:swap-ui" }); + const connectLocaleQuery = useConnectLocale(props.locale || "en_US"); + const activeWalletInfo = useActiveWalletInfo(); + const [buyToken, setBuyToken] = useState( + undefined, + ); + const [sellToken, setSellToken] = useState( + undefined, + ); + + // preload requests + useBridgeChains(props.client); + + if (!connectLocaleQuery.data) { + return ( +
+ +
+ ); + } + + if (screen.id === "1:swap-ui") { + return ( + { + setScreen({ quote, id: "2:loading-quote" }); + }} + /> + ); + } + + if ( + screen.id === "2:loading-quote" && + // TODO - cleanup + activeWalletInfo && + sellToken && + buyToken + ) { + return ( + { + // TODO + }} + onQuoteReceived={(preparedQuote, request) => { + setScreen({ + id: "3:preview", + preparedQuote, + request, + quote: screen.quote, + }); + // TODO + }} + receiver={activeWalletInfo.activeAccount.address} + onBack={() => setScreen({ id: "1:swap-ui" })} + uiOptions={{ + destinationToken: buyToken, + mode: "fund_wallet", + currency: props.currency, + }} + client={props.client} + destinationToken={buyToken} + paymentMethod={{ + quote: screen.quote, + type: "wallet", + payerWallet: activeWalletInfo.activeWallet, + balance: 0n, // TODO - what is this? + originToken: sellToken, + }} + /> + ); + } + + if ( + screen.id === "3:preview" && + // TODO - cleanup + activeWalletInfo && + sellToken && + buyToken + ) { + return ( + { + setScreen({ id: "1:swap-ui" }); + }} + onConfirm={() => { + setScreen({ + id: "4:execute", + preparedQuote: screen.preparedQuote, + request: screen.request, + quote: screen.quote, + }); + }} + onError={(_error) => { + // TODO + }} + paymentMethod={{ + quote: screen.quote, + type: "wallet", + payerWallet: activeWalletInfo.activeWallet, + balance: 0n, // TODO - what is this? + originToken: sellToken, + }} + preparedQuote={screen.preparedQuote} + uiOptions={{ + destinationToken: buyToken, + mode: "fund_wallet", + currency: props.currency, + }} + /> + ); + } + + if ( + screen.id === "4:execute" && + // TODO - cleanup + activeWalletInfo + ) { + return ( + { + setScreen({ + id: "3:preview", + preparedQuote: screen.preparedQuote, + request: screen.request, + quote: screen.quote, + }); + }} + onCancel={() => { + // TODO + }} + onComplete={() => { + // TODO + }} + request={screen.request} + wallet={activeWalletInfo.activeWallet} + windowAdapter={webWindowAdapter} + /> + ); + } + + return null; +} + +function useActiveWalletInfo() { + const activeAccount = useActiveAccount(); + const activeWallet = useActiveWallet(); + const activeChain = useActiveWalletChain(); + + return useMemo(() => { + return activeAccount && activeWallet && activeChain + ? { + activeChain, + activeWallet, + activeAccount, + } + : undefined; + }, [activeAccount, activeWallet, activeChain]); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-buy-token.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-buy-token.tsx new file mode 100644 index 00000000000..cabf9193463 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-buy-token.tsx @@ -0,0 +1,334 @@ +import { CheckIcon, DiscIcon } from "@radix-ui/react-icons"; +import { useEffect, useState } from "react"; +import type { Token, TokenWithPrices } from "../../../../../bridge/index.js"; +import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { getToken } from "../../../../../pay/convert/get-token.js"; +import { + fontSize, + iconSize, + radius, + spacing, +} from "../../../../core/design-system/index.js"; +import { useActiveWalletChain } from "../../../../core/hooks/wallets/useActiveWalletChain.js"; +import { Container, Line, ModalHeader } from "../../components/basic.js"; +import { Button } from "../../components/buttons.js"; +import { Img } from "../../components/Img.js"; +import { Skeleton } from "../../components/Skeleton.js"; +import { Spacer } from "../../components/Spacer.js"; +import { Spinner } from "../../components/Spinner.js"; +import { Text } from "../../components/text.js"; +import { SearchInput } from "./SearchInput.js"; +import { SelectChainButton } from "./SelectChainButton.js"; +import { SelectBridgeChain } from "./select-chain.js"; +import { useBridgeChains } from "./use-bridge-chains.js"; +import { useTokens } from "./use-tokens.js"; + +type SelectBuyTokenProps = { + onBack: () => void; + client: ThirdwebClient; + selectedToken: TokenWithPrices | undefined; + setSelectedToken: (token: TokenWithPrices) => void; +}; + +function getDefaultSelectedChain( + chains: BridgeChain[], + activeChainId: number | undefined, +) { + return chains.find((chain) => chain.chainId === (activeChainId || 1)); +} + +export function SelectBuyToken(props: SelectBuyTokenProps) { + const activeChain = useActiveWalletChain(); + const chainQuery = useBridgeChains(props.client); + const [search, setSearch] = useState(""); + const [limit, setLimit] = useState(1000); + + const [selectedChain, setSelectedChain] = useState( + () => { + if (!chainQuery.data) { + return undefined; + } + return getDefaultSelectedChain( + chainQuery.data, + props.selectedToken?.chainId || activeChain?.id, + ); + }, + ); + + useEffect(() => { + if (chainQuery.data && !selectedChain) { + setSelectedChain( + getDefaultSelectedChain( + chainQuery.data, + props.selectedToken?.chainId || activeChain?.id, + ), + ); + } + }, [ + chainQuery.data, + selectedChain, + activeChain?.id, + props.selectedToken?.chainId, + ]); + + const tokensQuery = useTokens({ + client: props.client, + chainId: selectedChain?.chainId, + search, + limit, + offset: 0, + }); + + return ( + { + setLimit(limit * 2); + } + : undefined + } + /> + ); +} + +export function SelectBuyTokenUI( + props: SelectBuyTokenProps & { + tokens: Token[]; + isPending: boolean; + selectedChain: BridgeChain | undefined; + setSelectedChain: (chain: BridgeChain) => void; + search: string; + setSearch: (search: string) => void; + selectedToken: TokenWithPrices | undefined; + setSelectedToken: (token: TokenWithPrices) => void; + showMore: (() => void) | undefined; + }, +) { + const [screen, setScreen] = useState<"select-chain" | "select-token">( + "select-token", + ); + + if (screen === "select-token") { + return ( + + + + + + + {!props.selectedChain && ( +
+ +
+ )} + + {props.selectedChain && ( + <> + + setScreen("select-chain")} + selectedChain={props.selectedChain} + client={props.client} + /> + + + {/* search */} + + + + + + + + {props.tokens.map((token) => ( + + ))} + + {props.showMore && ( + + )} + + {props.isPending && + new Array(20).fill(0).map(() => ( + // biome-ignore lint/correctness/useJsxKeyInIterable: ok + + ))} + + {props.tokens.length === 0 && !props.isPending && ( +
+ + No Tokens Found + +
+ )} +
+
+ + )} +
+ ); + } + + if (screen === "select-chain") { + return ( + setScreen("select-token")} + client={props.client} + onSelectChain={(chain) => { + props.setSelectedChain(chain); + setScreen("select-token"); + }} + selectedChain={props.selectedChain} + /> + ); + } + + return null; +} + +function TokenButton(props: { + token: Token; + client: ThirdwebClient; + onSelect: (tokenWithPrices: TokenWithPrices) => void; + isSelected: boolean; +}) { + const [isLoading, setIsLoading] = useState(false); + return ( + + ); +} + +function TokenButtonSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx new file mode 100644 index 00000000000..cdcef07f018 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import type { Chain as BridgeChain } from "../../../../../bridge/index.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { + fontSize, + iconSize, + spacing, +} from "../../../../core/design-system/index.js"; +import { Container, Line, ModalHeader } from "../../components/basic.js"; +import { Button } from "../../components/buttons.js"; +import { Img } from "../../components/Img.js"; +import { Skeleton } from "../../components/Skeleton.js"; +import { Spacer } from "../../components/Spacer.js"; +import { Text } from "../../components/text.js"; +import { SearchInput } from "./SearchInput.js"; +import { useBridgeChains } from "./use-bridge-chains.js"; +import { cleanedChainName } from "./utils.js"; + +type SelectBuyTokenProps = { + onBack: () => void; + client: ThirdwebClient; + onSelectChain: (chain: BridgeChain) => void; + selectedChain: BridgeChain | undefined; +}; + +export function SelectBridgeChain(props: SelectBuyTokenProps) { + const chainQuery = useBridgeChains(props.client); + + return ( + + ); +} + +export function SelectBridgeChainUI( + props: SelectBuyTokenProps & { + isPending: boolean; + chains: BridgeChain[]; + onSelectChain: (chain: BridgeChain) => void; + selectedChain: BridgeChain | undefined; + }, +) { + const [search, setSearch] = useState(""); + const filteredChains = props.chains.filter((chain) => { + return chain.name.toLowerCase().includes(search.toLowerCase()); + }); + + return ( +
+ + + + + + + + + + + + + + {filteredChains.map((chain) => ( + props.onSelectChain(chain)} + isSelected={chain.chainId === props.selectedChain?.chainId} + /> + ))} + + {props.isPending && + new Array(20).fill(0).map(() => ( + // biome-ignore lint/correctness/useJsxKeyInIterable: ok + + ))} + + {filteredChains.length === 0 && !props.isPending && ( +
+ + No chains found for "{search}" + +
+ )} +
+
+ ); +} + +function ChainButtonSkeleton() { + return ( +
+ + +
+ ); +} + +export function ChainButton(props: { + chain: BridgeChain; + client: ThirdwebClient; + onClick: () => void; + isSelected: boolean; +}) { + return ( + + ); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx new file mode 100644 index 00000000000..c4ac1762c58 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx @@ -0,0 +1,496 @@ +import { + Cross1Icon, + DiscIcon, + MagnifyingGlassIcon, +} from "@radix-ui/react-icons"; +import { useEffect, useState } from "react"; +import type { TokenWithPrices } from "../../../../../bridge/index.js"; +import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { getToken } from "../../../../../pay/convert/get-token.js"; +import { toTokens } from "../../../../../utils/units.js"; +import { + fontSize, + iconSize, + radius, + spacing, +} from "../../../../core/design-system/index.js"; +import { ConnectButton } from "../../ConnectWallet/ConnectButton.js"; +import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js"; +import { Container, Line, ModalHeader } from "../../components/basic.js"; +import { Button } from "../../components/buttons.js"; +import { Input } from "../../components/formElements.js"; +import { Img } from "../../components/Img.js"; +import { Skeleton } from "../../components/Skeleton.js"; +import { Spacer } from "../../components/Spacer.js"; +import { Spinner } from "../../components/Spinner.js"; +import { Text } from "../../components/text.js"; +import { SelectChainButton } from "./SelectChainButton.js"; +import { SelectBridgeChain } from "./select-chain.js"; +import type { ActiveWalletInfo } from "./types.js"; +import { useBridgeChains } from "./use-bridge-chains.js"; +import { type TokenBalance, useTokenBalances } from "./use-tokens.js"; + +type SelectSellTokenProps = { + onBack: () => void; + client: ThirdwebClient; + selectedToken: TokenWithPrices | undefined; + setSelectedToken: (token: TokenWithPrices) => void; +}; + +function getDefaultSelectedChain( + chains: BridgeChain[], + chainId: number | undefined, +) { + return chains.find((chain) => chain.chainId === (chainId || 1)); +} + +export function SelectSellToken( + props: SelectSellTokenProps & { + activeWalletInfo: ActiveWalletInfo | undefined; + }, +) { + if (props.activeWalletInfo) { + return ( + + ); + } + return ( + + ); +} + +export function SelectSellTokenDisconnectedUI(props: { + onBack: () => void; + client: ThirdwebClient; +}) { + return ( + + + + + + + + + + + + + Wallet is not connected + + + Connect your wallet to view your tokens + + + + + + + + ); +} + +export function SelectSellTokenConnected( + props: SelectSellTokenProps & { + activeWalletInfo: ActiveWalletInfo; + }, +) { + const chainQuery = useBridgeChains(props.client); + const [search, setSearch] = useState(""); + const [limit, setLimit] = useState(50); + + const [selectedChain, setSelectedChain] = useState( + () => { + if (!chainQuery.data) { + return undefined; + } + return getDefaultSelectedChain( + chainQuery.data, + props.selectedToken?.chainId || props.activeWalletInfo.activeChain.id, + ); + }, + ); + + useEffect(() => { + if (chainQuery.data && !selectedChain) { + setSelectedChain( + getDefaultSelectedChain( + chainQuery.data, + props.selectedToken?.chainId || props.activeWalletInfo.activeChain.id, + ), + ); + } + }, [ + chainQuery.data, + selectedChain, + props.activeWalletInfo.activeChain.id, + props.selectedToken?.chainId, + ]); + + // TODO - useTokenBalances doesn't support all the bridge chains, we need to add a fallback to show all the tokens and not just owned tokens when a chain is not supported + const tokensQuery = useTokenBalances({ + clientId: props.client.clientId, + chainId: selectedChain?.chainId, + limit, + page: 1, + walletAddress: props.activeWalletInfo.activeAccount.address, + }); + + return ( + { + setLimit(tokensQuery.data.pagination.totalCount); + } + : undefined + } + /> + ); +} + +export function SelectSellTokenConnectedUI( + props: SelectSellTokenProps & { + activeWalletInfo: ActiveWalletInfo; + tokens: TokenBalance[]; + isPending: boolean; + selectedChain: BridgeChain | undefined; + setSelectedChain: (chain: BridgeChain) => void; + search: string; + setSearch: (search: string) => void; + selectedToken: TokenWithPrices | undefined; + setSelectedToken: (token: TokenWithPrices) => void; + showAll: (() => void) | undefined; + }, +) { + const [screen, setScreen] = useState<"select-chain" | "select-token">( + "select-token", + ); + + const filteredTokens = props.tokens.filter((token) => { + return ( + token.symbol.toLowerCase().includes(props.search.toLowerCase()) || + token.name.toLowerCase().includes(props.search.toLowerCase()) || + token.token_address.toLowerCase().includes(props.search.toLowerCase()) + ); + }); + + if (screen === "select-token") { + return ( + + + + + + + {!props.selectedChain && ( +
+ +
+ )} + + {props.selectedChain && ( + <> + + setScreen("select-chain")} + selectedChain={props.selectedChain} + client={props.client} + /> + + + {/* search */} + +
+ + + + + props.setSearch(e.target.value)} + /> +
+
+ + + + {filteredTokens.map((token) => { + return ( + + ); + })} + + {props.showAll && ( + + )} + + {props.isPending && + new Array(20).fill(0).map(() => ( + // biome-ignore lint/correctness/useJsxKeyInIterable: ok + + ))} + + {filteredTokens.length === 0 && !props.isPending && ( +
+ + No Tokens Found + +
+ )} +
+
+ + )} +
+ ); + } + + if (screen === "select-chain") { + return ( + setScreen("select-token")} + client={props.client} + onSelectChain={(chain) => { + props.setSelectedChain(chain); + setScreen("select-token"); + }} + selectedChain={props.selectedChain} + /> + ); + } + + return null; +} + +function TokenButton(props: { + token: TokenBalance; + client: ThirdwebClient; + onSelect: (tokenWithPrices: TokenWithPrices) => void; + isSelected: boolean; +}) { + const [isLoading, setIsLoading] = useState(false); + const tokenBalanceInUnits = toTokens( + BigInt(props.token.balance), + props.token.decimals, + ); + const usdValue = + props.token.price_data.price_usd * Number(tokenBalanceInUnits); + + return ( + + ); +} + +function TokenButtonSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx new file mode 100644 index 00000000000..c3bfc7e091a --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -0,0 +1,541 @@ +import styled from "@emotion/styled"; +import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { Buy, Sell } from "../../../../../bridge/index.js"; +import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; +import type { TokenWithPrices } from "../../../../../bridge/types/Token.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { + getFiatSymbol, + type SupportedFiatCurrency, +} from "../../../../../pay/convert/type.js"; +import { toTokens, toUnits } from "../../../../../utils/units.js"; +import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; +import { + fontSize, + iconSize, + radius, + spacing, + type Theme, +} from "../../../../core/design-system/index.js"; +import { ConnectButton } from "../../ConnectWallet/ConnectButton.js"; +import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js"; +import { Container } from "../../components/basic.js"; +import { Button } from "../../components/buttons.js"; +import { Input } from "../../components/formElements.js"; +import { Img } from "../../components/Img.js"; +import { Skeleton } from "../../components/Skeleton.js"; +import { Spacer } from "../../components/Spacer.js"; +import { Text } from "../../components/text.js"; +import { SelectBuyToken } from "./select-buy-token.js"; +import { SelectSellToken } from "./select-sell-token.js"; +import type { ActiveWalletInfo, SwapWidgetConnectOptions } from "./types.js"; +import { useBridgeChains } from "./use-bridge-chains.js"; +import { cleanedChainName } from "./utils.js"; + +type SwapUIProps = { + activeWalletInfo: ActiveWalletInfo | undefined; + client: ThirdwebClient; + theme: Theme | "light" | "dark"; + connectOptions: SwapWidgetConnectOptions | undefined; + currency: SupportedFiatCurrency; + buyToken: TokenWithPrices | undefined; + sellToken: TokenWithPrices | undefined; + setBuyToken: (token: TokenWithPrices | undefined) => void; + setSellToken: (token: TokenWithPrices | undefined) => void; + onSwap: (quote: Buy.quote.Result | Sell.quote.Result) => void; +}; + +export function SwapUI(props: SwapUIProps) { + const [screen, setScreen] = useState< + "base" | "select-buy-token" | "select-sell-ui" + >("base"); + + if (screen === "base") { + return ( + { + setScreen(type === "buy" ? "select-buy-token" : "select-sell-ui"); + }} + /> + ); + } + + if (screen === "select-buy-token") { + return ( + setScreen("base")} + client={props.client} + selectedToken={props.buyToken} + setSelectedToken={(token) => { + props.setBuyToken(token); + setScreen("base"); + }} + /> + ); + } + + if (screen === "select-sell-ui") { + return ( + setScreen("base")} + client={props.client} + selectedToken={props.sellToken} + setSelectedToken={(token) => { + props.setSellToken(token); + setScreen("base"); + }} + activeWalletInfo={props.activeWalletInfo} + /> + ); + } + + return null; +} + +export function SwapUIBase( + props: SwapUIProps & { onSelectToken: (type: "buy" | "sell") => void }, +) { + const [mode, setMode] = useState<"buy" | "sell">("buy"); + const [_buyTokenAmount, setBuyTokenAmount] = useState(""); + const [_sellTokenAmount, setSellTokenAmount] = useState(""); + + const preparedResultQuery = useQuery({ + queryKey: [ + "swap-quote", + mode, + props.buyToken, + props.sellToken, + _sellTokenAmount, + _buyTokenAmount, + ], + enabled: + !!props.buyToken && + !!props.sellToken && + (mode === "buy" ? !!_buyTokenAmount : !!_sellTokenAmount), + queryFn: async () => { + if (!props.buyToken || !props.sellToken || !props.activeWalletInfo) { + return; + } + + if (mode === "buy" && _buyTokenAmount) { + const res = await Buy.quote({ + buyAmountWei: toUnits(_buyTokenAmount, props.buyToken.decimals), + // origin = sell + originChainId: props.sellToken.chainId, + originTokenAddress: props.sellToken.address, + // destination = buy + destinationChainId: props.buyToken.chainId, + destinationTokenAddress: props.buyToken.address, + client: props.client, + }); + + return res; + } else if (mode === "sell" && _sellTokenAmount) { + const res = await Sell.prepare({ + amount: toUnits(_sellTokenAmount, props.sellToken.decimals), + // origin = sell + originChainId: props.sellToken.chainId, + originTokenAddress: props.sellToken.address, + // destination = buy + destinationChainId: props.buyToken.chainId, + destinationTokenAddress: props.buyToken.address, + client: props.client, + receiver: props.activeWalletInfo.activeAccount.address, + sender: props.activeWalletInfo.activeAccount.address, + }); + + return res; + } + + return null; + }, + refetchInterval: 20000, + }); + + const sellTokenAmount = + preparedResultQuery.data && mode === "buy" && props.sellToken + ? toTokens( + preparedResultQuery.data.originAmount, + props.sellToken.decimals, + ) + : _sellTokenAmount; + + const buyTokenAmount = + preparedResultQuery.data && mode === "sell" && props.buyToken + ? toTokens( + preparedResultQuery.data.destinationAmount, + props.buyToken.decimals, + ) + : _buyTokenAmount; + + const isBuyAmountFetching = mode === "sell" && preparedResultQuery.isFetching; + const isSellAmountFetching = mode === "buy" && preparedResultQuery.isFetching; + + return ( + + {/* Sell */} + { + setSellTokenAmount(value); + setBuyTokenAmount(""); + setMode("sell"); + }} + selectedToken={props.sellToken} + client={props.client} + currency={props.currency} + onSelectToken={() => props.onSelectToken("sell")} + /> + + {/* Switch */} + { + // switch tokens + const temp = props.sellToken; + props.setSellToken(props.buyToken); + props.setBuyToken(temp); + // reset amounts + setSellTokenAmount(""); + setBuyTokenAmount(""); + }} + /> + + {/* Buy */} + { + setBuyTokenAmount(value); + setSellTokenAmount(""); + setMode("buy"); + }} + client={props.client} + currency={props.currency} + onSelectToken={() => props.onSelectToken("buy")} + /> + + + + {/* Button */} + {!props.activeWalletInfo ? ( + + ) : ( + + )} + + ); +} + +function DecimalInput(props: { + value: string; + setValue: (value: string) => void; +}) { + const handleAmountChange = (inputValue: string) => { + let processedValue = inputValue; + + // Replace comma with period if it exists + processedValue = processedValue.replace(",", "."); + + if (processedValue.startsWith(".")) { + processedValue = `0${processedValue}`; + } + + const numValue = Number(processedValue); + if (Number.isNaN(numValue)) { + return; + } + + if (processedValue.startsWith("0") && !processedValue.startsWith("0.")) { + props.setValue(processedValue.slice(1)); + } else { + props.setValue(processedValue); + } + }; + + return ( + { + handleAmountChange(e.target.value); + }} + onClick={(e) => { + // put cursor at the end of the input + if (props.value === "") { + e.currentTarget.setSelectionRange( + e.currentTarget.value.length, + e.currentTarget.value.length, + ); + } + }} + pattern="^[0-9]*[.,]?[0-9]*$" + placeholder="0.0" + style={{ + border: "none", + boxShadow: "none", + fontSize: fontSize.xxl, + fontWeight: 500, + paddingInline: 0, + paddingBlock: 0, + }} + type="text" + value={props.value} + variant="transparent" + /> + ); +} + +function TokenSection(props: { + label: string; + amount: string; + isPending: boolean; + setAmount: (amount: string) => void; + selectedToken: TokenWithPrices | undefined; + currency: SupportedFiatCurrency; + onSelectToken: () => void; + client: ThirdwebClient; +}) { + const chainQuery = useBridgeChains(props.client); + const chain = chainQuery.data?.find( + (chain) => chain.chainId === props.selectedToken?.chainId, + ); + + const fiatPricePerToken = props.selectedToken?.prices[props.currency]; + const totalFiatValue = !props.amount + ? undefined + : fiatPricePerToken + ? fiatPricePerToken * Number(props.amount) + : undefined; + + return ( + + {/* label */} + + {props.label} + + +
+ {props.isPending ? ( + + ) : ( + + )} + + {!props.selectedToken ? ( + + ) : ( + + )} +
+ + {/* Fiat Value */} +
+ + {getFiatSymbol(props.currency)} + + + {totalFiatValue === undefined + ? "0.00" + : totalFiatValue < 0.01 + ? "~0.00" + : totalFiatValue.toFixed(2)} + +
+
+ ); +} + +function SelectedTokenButton(props: { + selectedToken: TokenWithPrices; + client: ThirdwebClient; + onSelectToken: () => void; + chain: BridgeChain | undefined; +}) { + return ( + + ); +} + +function SwitchButton(props: { onClick: () => void }) { + return ( +
+ { + props.onClick(); + const node = e.currentTarget.querySelector("svg"); + if (node) { + node.style.transform = "rotate(180deg)"; + node.style.transition = "transform 300ms ease"; + setTimeout(() => { + node.style.transition = ""; + node.style.transform = "rotate(0deg)"; + }, 300); + } + }} + > + + +
+ ); +} + +const SwitchButtonInner = /* @__PURE__ */ styled(Button)(() => { + const theme = useCustomTheme(); + return { + "&:hover": { + background: theme.colors.modalBg, + }, + borderRadius: radius.lg, + padding: spacing.xs, + background: theme.colors.modalBg, + border: `1px solid ${theme.colors.borderColor}`, + }; +}); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts new file mode 100644 index 00000000000..858d4aba64d --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts @@ -0,0 +1,141 @@ +import type { Chain } from "../../../../../chains/types.js"; +import type { + Account, + Wallet, +} from "../../../../../wallets/interfaces/wallet.js"; +import type { SmartWalletOptions } from "../../../../../wallets/smart/types.js"; +import type { AppMetadata } from "../../../../../wallets/types.js"; +import type { SiweAuthOptions } from "../../../../core/hooks/auth/useSiweAuth.js"; +import type { ConnectButton_connectModalOptions } from "../../../../core/hooks/connection/ConnectButtonProps.js"; + +/** + * Connection options for the `BuyWidget` component + * + * @example + * ```tsx + * + * ``` + */ +export type SwapWidgetConnectOptions = { + /** + * Configurations for the `ConnectButton`'s Modal that is shown for connecting a wallet + * Refer to the [`ConnectButton_connectModalOptions`](https://portal.thirdweb.com/references/typescript/v5/ConnectButton_connectModalOptions) type for more details + */ + connectModal?: ConnectButton_connectModalOptions; + + /** + * Configure options for WalletConnect + * + * By default WalletConnect uses the thirdweb's default project id. + * Setting your own project id is recommended. + * + * You can create a project id by signing up on [walletconnect.com](https://walletconnect.com/) + */ + walletConnect?: { + projectId?: string; + }; + + /** + * Enable Account abstraction for all wallets. This will connect to the users's smart account based on the connected personal wallet and the given options. + * + * This allows to sponsor gas fees for your user's transaction using the thirdweb account abstraction infrastructure. + * + */ + accountAbstraction?: SmartWalletOptions; + + /** + * Array of wallets to show in Connect Modal. If not provided, default wallets will be used. + */ + wallets?: Wallet[]; + /** + * When the user has connected their wallet to your site, this configuration determines whether or not you want to automatically connect to the last connected wallet when user visits your site again in the future. + * + * By default it is set to `{ timeout: 15000 }` meaning that autoConnect is enabled and if the autoConnection does not succeed within 15 seconds, it will be cancelled. + * + * If you want to disable autoConnect, set this prop to `false`. + * + * If you want to customize the timeout, you can assign an object with a `timeout` key to this prop. + * ``` + */ + autoConnect?: + | { + timeout: number; + } + | boolean; + + /** + * Metadata of the app that will be passed to connected wallet. Setting this is highly recommended. + */ + appMetadata?: AppMetadata; + + /** + * The [`Chain`](https://portal.thirdweb.com/references/typescript/v5/Chain) object of the blockchain you want the wallet to connect to + * + * If a `chain` is not specified, Wallet will be connected to whatever is the default set in the wallet. + * + * If a `chain` is specified, Wallet will be prompted to switch to given chain after connection if it is not already connected to it. + * This ensures that the wallet is connected to the correct blockchain before interacting with your app. + * + * The `ConnectButton` also shows a "Switch Network" button until the wallet is connected to the specified chain. Clicking on the "Switch Network" button triggers the wallet to switch to the specified chain. + * + * You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function. + * At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object. + * ``` + */ + chain?: Chain; + + /** + * Array of chains that your app supports. + * + * This is only relevant if your app is a multi-chain app and works across multiple blockchains. + * If your app only works on a single blockchain, you should only specify the `chain` prop. + * + * Given list of chains will used in various ways: + * - They will be displayed in the network selector in the `ConnectButton`'s details modal post connection + * - They will be sent to wallet at the time of connection if the wallet supports requesting multiple chains ( example: WalletConnect ) so that users can switch between the chains post connection easily + * + * You can create a `Chain` object using the [`defineChain`](https://portal.thirdweb.com/references/typescript/v5/defineChain) function. + * At minimum, you need to pass the `id` of the blockchain to `defineChain` function to create a `Chain` object. + * + * ```tsx + * import { defineChain } from "thirdweb/react"; + * + * const polygon = defineChain({ + * id: 137, + * }); + * ``` + */ + chains?: Chain[]; + + /** + * Wallets to show as recommended in the `ConnectButton`'s Modal + */ + recommendedWallets?: Wallet[]; + + /** + * By default, ConnectButton modal shows a "All Wallets" button that shows a list of 500+ wallets. + * + * You can disable this button by setting `showAllWallets` prop to `false` + */ + showAllWallets?: boolean; + + /** + * Enable SIWE (Sign in with Ethererum) by passing an object of type `SiweAuthOptions` to + * enforce the users to sign a message after connecting their wallet to authenticate themselves. + * + * Refer to the [`SiweAuthOptions`](https://portal.thirdweb.com/references/typescript/v5/SiweAuthOptions) for more details + */ + auth?: SiweAuthOptions; +}; + +export type ActiveWalletInfo = { + activeChain: Chain; + activeWallet: Wallet; + activeAccount: Account; +}; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts new file mode 100644 index 00000000000..f7eb7ecf2d3 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { chains } from "../../../../../bridge/index.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; + +export function useBridgeChains(client: ThirdwebClient) { + return useQuery({ + queryKey: ["bridge-chains"], + queryFn: () => { + return chains({ client }); + }, + }); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts new file mode 100644 index 00000000000..3960e21e971 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts @@ -0,0 +1,115 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Token } from "../../../../../bridge/index.js"; +import { tokens } from "../../../../../bridge/Token.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { isAddress } from "../../../../../utils/address.js"; + +export function useTokens(options: { + client: ThirdwebClient; + chainId?: number; + search?: string; + offset: number; + limit: number; +}) { + return useQuery({ + queryKey: ["tokens", options], + enabled: !!options.chainId, + queryFn: () => { + if (!options.chainId) { + throw new Error("Chain ID is required"); + } + + const isSearchAddress = options.search + ? isAddress(options.search) + : false; + + return tokens({ + chainId: options.chainId, + client: options.client, + offset: options.offset, + limit: options.limit, + includePrices: false, + name: !options.search || isSearchAddress ? undefined : options.search, + tokenAddress: isSearchAddress ? options.search : undefined, + }); + }, + }); +} + +export type TokenBalance = { + balance: string; + chain_id: number; + decimals: 1; + name: string; + icon_uri: string; + price_data: { + circulating_supply: number; + market_cap_usd: number; + percent_change_24h: number; + price_timestamp: string; + price_usd: number; + total_supply: number; + usd_value: number; + volume_24h_usd: number; + }; + symbol: string; + token_address: string; +}; + +export type TokenBalancesResponse = { + result: { + pagination: { + hasMore: boolean; + limit: number; + page: number; + totalCount: number; + }; + tokens: TokenBalance[]; + }; +}; + +export function useTokenBalances(options: { + clientId: string; + page: number; + limit: number; + walletAddress: string; + chainId: number | undefined; +}) { + return useQuery({ + queryKey: ["bridge/v1/wallets", options], + enabled: !!options.chainId, + queryFn: async () => { + if (!options.chainId) { + throw new Error("Chain ID is required"); + } + const url = new URL( + `https://api.thirdweb.com/v1/wallets/${options.walletAddress}/tokens`, + ); + url.searchParams.set("chainId", options.chainId.toString()); + url.searchParams.set("limit", options.limit.toString()); + url.searchParams.set("page", options.page.toString()); + url.searchParams.set("metadata", "true"); + url.searchParams.set("resolveMetadataLinks", "true"); + url.searchParams.set("includeSpam", "false"); + url.searchParams.set("includeNative", "true"); + url.searchParams.set("sortBy", "usd_value"); + url.searchParams.set("sortOrder", "desc"); + url.searchParams.set("includeWithoutPrice", "false"); // filter out tokens with no price + + const response = await fetch(url.toString(), { + headers: { + "x-client-id": options.clientId, + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch token balances: ${response.statusText}`, + ); + } + + const json = (await response.json()) as TokenBalancesResponse; + return json.result; + }, + }); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts new file mode 100644 index 00000000000..a92d79e481d --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts @@ -0,0 +1,3 @@ +export function cleanedChainName(name: string) { + return name.replace("Mainnet", ""); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/ArrowUpDownIcon.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/ArrowUpDownIcon.tsx new file mode 100644 index 00000000000..f97ba66fb65 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/ArrowUpDownIcon.tsx @@ -0,0 +1,23 @@ +import type { IconFC } from "./types.js"; + +export const ArrowUpDownIcon: IconFC = (props) => { + return ( + + + + + + + ); +}; diff --git a/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx b/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx index aaacbf792ab..a3b82b74f36 100644 --- a/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx +++ b/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx @@ -17,7 +17,7 @@ export function DynamicHeight(props: { boxSizing: "border-box", height: height ? `${height}px` : "auto", overflow: "hidden", - transition: "height 210ms cubic-bezier(0.175, 0.885, 0.32, 1.1)", + transition: "height 210ms ease", }} >
= (props) => { - const [isLoaded, setIsLoaded] = useState(false); + const [_status, setStatus] = useState<"pending" | "fallback" | "loaded">( + "pending", + ); + + const status = + props.src === undefined + ? "pending" + : props.src === "" + ? "fallback" + : _status; const propSrc = props.src; @@ -41,6 +53,7 @@ export const Img: React.FC<{ }; const src = getSrc(); + const isLoaded = status === "loaded"; return (
- {!isLoaded && } + {status === "pending" && } + {status === "fallback" && + (props.fallback || ( + +
+ + ))} + {props.alt { - setIsLoaded(true); + setStatus("loaded"); }} src={src} style={{ diff --git a/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx b/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx index 89e404deb63..0e2d99ac29e 100644 --- a/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx @@ -12,6 +12,7 @@ export const Skeleton: React.FC<{ width?: string; color?: keyof Theme["colors"]; className?: string; + style?: React.CSSProperties; }> = (props) => { return ( ); diff --git a/packages/thirdweb/src/react/web/ui/components/Spinner.tsx b/packages/thirdweb/src/react/web/ui/components/Spinner.tsx index 11153f1ac75..4a1cd57c3c9 100644 --- a/packages/thirdweb/src/react/web/ui/components/Spinner.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Spinner.tsx @@ -10,6 +10,7 @@ import { StyledCircle, StyledSvg } from "../design-system/elements.js"; */ export const Spinner: React.FC<{ size: keyof typeof iconSize; + style?: React.CSSProperties; color?: keyof Theme["colors"]; }> = (props) => { const theme = useCustomTheme(); @@ -18,6 +19,7 @@ export const Spinner: React.FC<{ style={{ height: `${iconSize[props.size]}px`, width: `${iconSize[props.size]}px`, + ...props.style, }} viewBox="0 0 50 50" > diff --git a/packages/thirdweb/src/react/web/ui/components/basic.tsx b/packages/thirdweb/src/react/web/ui/components/basic.tsx index 4ce5a16fd5d..0663ffc6bf9 100644 --- a/packages/thirdweb/src/react/web/ui/components/basic.tsx +++ b/packages/thirdweb/src/react/web/ui/components/basic.tsx @@ -64,11 +64,10 @@ export function ModalHeader(props: { ); } -export const Line = /* @__PURE__ */ StyledDiv(() => { +export const Line = /* @__PURE__ */ StyledDiv((props: { dashed?: boolean }) => { const theme = useCustomTheme(); return { - background: theme.colors.separatorLine, - height: "1px", + borderTop: `1px ${props.dashed ? "dashed" : "solid"} ${theme.colors.separatorLine}`, }; }); diff --git a/packages/thirdweb/src/react/web/ui/components/buttons.tsx b/packages/thirdweb/src/react/web/ui/components/buttons.tsx index 41ee93cf868..655d9963204 100644 --- a/packages/thirdweb/src/react/web/ui/components/buttons.tsx +++ b/packages/thirdweb/src/react/web/ui/components/buttons.tsx @@ -9,7 +9,14 @@ import { import { StyledButton } from "../design-system/elements.js"; type ButtonProps = { - variant: "primary" | "secondary" | "link" | "accent" | "outline" | "ghost"; + variant: + | "primary" + | "secondary" + | "link" + | "accent" + | "outline" + | "ghost" + | "ghost-solid"; unstyled?: boolean; fullWidth?: boolean; gap?: keyof typeof spacing; @@ -65,6 +72,7 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => { case "secondary": return theme.colors.secondaryButtonText; case "ghost": + case "ghost-solid": case "outline": return theme.colors.secondaryButtonText; case "link": @@ -109,6 +117,15 @@ export const Button = /* @__PURE__ */ StyledButton((props: ButtonProps) => { }; } + if (props.variant === "ghost-solid") { + return { + "&:hover": { + background: theme.colors.tertiaryBg, + }, + border: "1px solid transparent", + }; + } + if (props.variant === "accent") { return { "&:hover": { diff --git a/packages/thirdweb/src/react/web/ui/components/formElements.tsx b/packages/thirdweb/src/react/web/ui/components/formElements.tsx index 1758ba9eaca..7b75e0b60b8 100644 --- a/packages/thirdweb/src/react/web/ui/components/formElements.tsx +++ b/packages/thirdweb/src/react/web/ui/components/formElements.tsx @@ -30,6 +30,7 @@ type InputProps = { variant: "outline" | "transparent"; sm?: boolean; theme?: Theme; + bg?: keyof Theme["colors"]; }; export const Input = /* @__PURE__ */ StyledInput((props) => { @@ -86,7 +87,7 @@ export const Input = /* @__PURE__ */ StyledInput((props) => { WebkitAppearance: "none", }, appearance: "none", - background: "transparent", + background: props.bg ? theme.colors[props.bg] : "transparent", border: "none", borderRadius: radius.md, boxShadow: `0 0 0 1.5px ${ diff --git a/packages/thirdweb/src/react/web/ui/components/text.tsx b/packages/thirdweb/src/react/web/ui/components/text.tsx index 8d50fc5ee6a..3194269b0d3 100644 --- a/packages/thirdweb/src/react/web/ui/components/text.tsx +++ b/packages/thirdweb/src/react/web/ui/components/text.tsx @@ -12,6 +12,7 @@ export type TextProps = { weight?: 400 | 500 | 600 | 700; multiline?: boolean; balance?: boolean; + trackingTight?: boolean; }; export const Text = /* @__PURE__ */ StyledSpan((p) => { @@ -20,7 +21,7 @@ export const Text = /* @__PURE__ */ StyledSpan((p) => { color: theme.colors[p.color || "secondaryText"], display: p.inline ? "inline" : "block", fontSize: fontSize[p.size || "md"], - fontWeight: p.weight || 500, + fontWeight: p.weight || 400, lineHeight: p.multiline ? 1.5 : "normal", margin: 0, maxWidth: "100%", @@ -28,6 +29,7 @@ export const Text = /* @__PURE__ */ StyledSpan((p) => { textAlign: p.center ? "center" : "left", textOverflow: "ellipsis", textWrap: p.balance ? "balance" : "inherit", + letterSpacing: p.trackingTight ? "-0.025em" : undefined, }; }); diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SelectBuyToken.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SelectBuyToken.stories.tsx new file mode 100644 index 00000000000..b6875696254 --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Swap/SelectBuyToken.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta } from "@storybook/react-vite"; +import { useState } from "react"; +import type { BridgeChain } from "../../../bridge/types/Chain.js"; +import { SwapWidgetContainer } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; +import { + SelectBuyToken, + SelectBuyTokenUI, +} from "../../../react/web/ui/Bridge/swap-widget/select-buy-token.js"; +import { storyClient } from "../../utils.js"; + +const meta = { + parameters: { + layout: "centered", + }, + title: "Bridge/Swap/screens/SelectBuyTokenUI", +} satisfies Meta; +export default meta; + +export function ChainLoading() { + const [selectedChain, setSelectedChain] = useState( + undefined, + ); + return ( + + {}} + selectedChain={selectedChain} + tokens={[]} + isPending={true} + selectedToken={undefined} + setSelectedToken={() => {}} + search={""} + showMore={() => {}} + setSearch={() => {}} + /> + + ); +} + +export function WithData() { + return ( + + {}} + selectedToken={undefined} + setSelectedToken={() => {}} + /> + + ); +} diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx new file mode 100644 index 00000000000..0d5e4508fab --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta } from "@storybook/react-vite"; +import { useState } from "react"; +import type { BridgeChain } from "../../../bridge/types/Chain.js"; +import { SwapWidgetContainer } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; +import { + SelectBridgeChain, + SelectBridgeChainUI, +} from "../../../react/web/ui/Bridge/swap-widget/select-chain.js"; +import { storyClient } from "../../utils.js"; + +const meta = { + parameters: { + layout: "centered", + }, + title: "Bridge/Swap/screens/SelectChain", +} satisfies Meta; +export default meta; + +export function WithData() { + const [selectedChain, setSelectedChain] = useState( + undefined, + ); + return ( + + {}} + selectedChain={selectedChain} + /> + + ); +} + +export function Loading() { + const [selectedChain, setSelectedChain] = useState( + undefined, + ); + return ( + + {}} + isPending={true} + chains={[]} + selectedChain={selectedChain} + /> + + ); +} diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx new file mode 100644 index 00000000000..d31a53f58cf --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta } from "@storybook/react-vite"; +import { useState } from "react"; +import type { BridgeChain } from "../../../bridge/types/Chain.js"; +import { useActiveAccount } from "../../../react/core/hooks/wallets/useActiveAccount.js"; +import { useActiveWallet } from "../../../react/core/hooks/wallets/useActiveWallet.js"; +import { useActiveWalletChain } from "../../../react/core/hooks/wallets/useActiveWalletChain.js"; +import { SwapWidgetContainer } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; +import { + type SelectSellToken, + SelectSellTokenConnectedUI, + SelectSellTokenDisconnectedUI, +} from "../../../react/web/ui/Bridge/swap-widget/select-sell-token.js"; +import type { ActiveWalletInfo } from "../../../react/web/ui/Bridge/swap-widget/types.js"; +import { ConnectButton } from "../../../react/web/ui/ConnectWallet/ConnectButton.js"; +import { storyClient } from "../../utils.js"; + +const meta = { + parameters: { + layout: "centered", + }, + title: "Bridge/Swap/screens/SelectSellTokenUI", +} satisfies Meta; +export default meta; + +export function ChainLoading() { + const [selectedChain, setSelectedChain] = useState( + undefined, + ); + + const activeChain = useActiveWalletChain(); + const activeWallet = useActiveWallet(); + const activeAccount = useActiveAccount(); + + const activeWalletInfo: ActiveWalletInfo | undefined = + activeAccount && activeWallet && activeChain + ? { + activeChain, + activeWallet, + activeAccount, + } + : undefined; + + if (!activeWalletInfo) { + return ( +
+

connect wallet to view story

+ +
+ ); + } + + return ( + + {}} + selectedChain={selectedChain} + tokens={[]} + isPending={true} + selectedToken={undefined} + setSelectedToken={() => {}} + search={""} + showAll={() => {}} + setSearch={() => {}} + activeWalletInfo={activeWalletInfo} + /> + + ); +} + +export function Disconnected() { + return ( + + {}} /> + + ); +} diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx new file mode 100644 index 00000000000..5b26563f9ca --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta } from "@storybook/react-vite"; +import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; +import { storyClient } from "../../utils.js"; + +const meta = { + parameters: { + layout: "centered", + }, + title: "Bridge/Swap/SwapWidget", +} satisfies Meta; +export default meta; + +export function BasicUsage() { + return ; +} + +export function CurrencySet() { + return ; +} + +export function LightMode() { + return ; +} diff --git a/packages/thirdweb/src/stories/BuyWidget.stories.tsx b/packages/thirdweb/src/stories/BuyWidget.stories.tsx new file mode 100644 index 00000000000..bdc0123d47c --- /dev/null +++ b/packages/thirdweb/src/stories/BuyWidget.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta } from "@storybook/react-vite"; +import { base } from "../chains/chain-definitions/base.js"; +import { defineChain } from "../chains/utils.js"; +import { BuyWidget } from "../react/web/ui/Bridge/BuyWidget.js"; +import { storyClient } from "./utils.js"; + +const meta = { + parameters: { + layout: "centered", + }, + title: "Connect/BuyWidget", +} satisfies Meta; +export default meta; + +export function BasicUsage() { + return ; +} + +export function UnsupportedChain() { + return ( + + ); +} + +export function UnsupportedToken() { + return ( + + ); +} From c917607bdf53b31bd90cf3cacaa17bb690987da4 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Sat, 13 Sep 2025 03:48:20 +0530 Subject: [PATCH 02/36] screen cleanup --- .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 119 ++++++------------ .../react/web/ui/Bridge/swap-widget/hooks.ts | 21 ++++ .../web/ui/Bridge/swap-widget/swap-ui.tsx | 46 +++++-- 3 files changed, 90 insertions(+), 96 deletions(-) create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/hooks.ts diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 936583b12de..5cf25de137d 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import type { Buy, Sell } from "../../../../../bridge/index.js"; import type { TokenWithPrices } from "../../../../../bridge/types/Token.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; @@ -12,18 +12,14 @@ import type { BridgePrepareRequest, BridgePrepareResult, } from "../../../../core/hooks/useBridgePrepare.js"; -import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccount.js"; -import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js"; -import { useActiveWalletChain } from "../../../../core/hooks/wallets/useActiveWalletChain.js"; import { webWindowAdapter } from "../../../adapters/WindowAdapter.js"; -import { useConnectLocale } from "../../ConnectWallet/locale/getConnectLocale.js"; import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js"; import { DynamicHeight } from "../../components/DynamicHeight.js"; -import { Spinner } from "../../components/Spinner.js"; import type { LocaleId } from "../../types.js"; import { PaymentDetails } from "../payment-details/PaymentDetails.js"; import { QuoteLoader } from "../QuoteLoader.js"; import { StepRunner } from "../StepRunner.js"; +import { useActiveWalletInfo } from "./hooks.js"; import { SwapUI } from "./swap-ui.js"; import type { SwapWidgetConnectOptions } from "./types.js"; import { useBridgeChains } from "./use-bridge-chains.js"; @@ -85,117 +81,96 @@ export function SwapWidgetContainer(props: { type SwapWidgetScreen = | { id: "1:swap-ui" } - | { id: "2:loading-quote"; quote: Buy.quote.Result | Sell.quote.Result } + | { + id: "2:loading-quote"; + quote: Buy.quote.Result | Sell.quote.Result; + buyToken: TokenWithPrices; + sellToken: TokenWithPrices; + } | { id: "3:preview"; preparedQuote: BridgePrepareResult; request: BridgePrepareRequest; quote: Buy.quote.Result | Sell.quote.Result; + buyToken: TokenWithPrices; + sellToken: TokenWithPrices; } | { id: "4:execute"; request: BridgePrepareRequest; quote: Buy.quote.Result | Sell.quote.Result; preparedQuote: BridgePrepareResult; + buyToken: TokenWithPrices; + sellToken: TokenWithPrices; }; function SwapWidgetContent(props: SwapWidgetProps) { const [screen, setScreen] = useState({ id: "1:swap-ui" }); - const connectLocaleQuery = useConnectLocale(props.locale || "en_US"); const activeWalletInfo = useActiveWalletInfo(); - const [buyToken, setBuyToken] = useState( - undefined, - ); - const [sellToken, setSellToken] = useState( - undefined, - ); // preload requests useBridgeChains(props.client); - if (!connectLocaleQuery.data) { - return ( -
- -
- ); - } - - if (screen.id === "1:swap-ui") { + // if wallet suddenly disconnects, show screen 1 + if (screen.id === "1:swap-ui" || !activeWalletInfo) { return ( { - setScreen({ quote, id: "2:loading-quote" }); + onSwap={(quote, selection) => { + setScreen({ + quote, + id: "2:loading-quote", + ...selection, + }); }} /> ); } - if ( - screen.id === "2:loading-quote" && - // TODO - cleanup - activeWalletInfo && - sellToken && - buyToken - ) { + if (screen.id === "2:loading-quote") { return ( { // TODO }} onQuoteReceived={(preparedQuote, request) => { setScreen({ + ...screen, id: "3:preview", preparedQuote, request, - quote: screen.quote, }); // TODO }} receiver={activeWalletInfo.activeAccount.address} onBack={() => setScreen({ id: "1:swap-ui" })} uiOptions={{ - destinationToken: buyToken, + destinationToken: screen.buyToken, mode: "fund_wallet", currency: props.currency, }} client={props.client} - destinationToken={buyToken} + destinationToken={screen.buyToken} paymentMethod={{ quote: screen.quote, type: "wallet", payerWallet: activeWalletInfo.activeWallet, balance: 0n, // TODO - what is this? - originToken: sellToken, + originToken: screen.sellToken, }} /> ); } - if ( - screen.id === "3:preview" && - // TODO - cleanup - activeWalletInfo && - sellToken && - buyToken - ) { + if (screen.id === "3:preview") { return ( { setScreen({ + ...screen, id: "4:execute", - preparedQuote: screen.preparedQuote, - request: screen.request, - quote: screen.quote, }); }} onError={(_error) => { @@ -218,11 +191,11 @@ function SwapWidgetContent(props: SwapWidgetProps) { type: "wallet", payerWallet: activeWalletInfo.activeWallet, balance: 0n, // TODO - what is this? - originToken: sellToken, + originToken: screen.sellToken, }} preparedQuote={screen.preparedQuote} uiOptions={{ - destinationToken: buyToken, + destinationToken: screen.buyToken, mode: "fund_wallet", currency: props.currency, }} @@ -230,21 +203,15 @@ function SwapWidgetContent(props: SwapWidgetProps) { ); } - if ( - screen.id === "4:execute" && - // TODO - cleanup - activeWalletInfo - ) { + if (screen.id === "4:execute") { return ( { setScreen({ + ...screen, id: "3:preview", - preparedQuote: screen.preparedQuote, - request: screen.request, - quote: screen.quote, }); }} onCancel={() => { @@ -262,19 +229,3 @@ function SwapWidgetContent(props: SwapWidgetProps) { return null; } - -function useActiveWalletInfo() { - const activeAccount = useActiveAccount(); - const activeWallet = useActiveWallet(); - const activeChain = useActiveWalletChain(); - - return useMemo(() => { - return activeAccount && activeWallet && activeChain - ? { - activeChain, - activeWallet, - activeAccount, - } - : undefined; - }, [activeAccount, activeWallet, activeChain]); -} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/hooks.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/hooks.ts new file mode 100644 index 00000000000..5555f3e3dd8 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/hooks.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; +import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccount.js"; +import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js"; +import { useActiveWalletChain } from "../../../../core/hooks/wallets/useActiveWalletChain.js"; +import type { ActiveWalletInfo } from "./types.js"; + +export function useActiveWalletInfo(): ActiveWalletInfo | undefined { + const activeAccount = useActiveAccount(); + const activeWallet = useActiveWallet(); + const activeChain = useActiveWalletChain(); + + return useMemo(() => { + return activeAccount && activeWallet && activeChain + ? { + activeChain, + activeWallet, + activeAccount, + } + : undefined; + }, [activeAccount, activeWallet, activeChain]); +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index c3bfc7e091a..77f9ace0ea4 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -40,11 +40,13 @@ type SwapUIProps = { theme: Theme | "light" | "dark"; connectOptions: SwapWidgetConnectOptions | undefined; currency: SupportedFiatCurrency; - buyToken: TokenWithPrices | undefined; - sellToken: TokenWithPrices | undefined; - setBuyToken: (token: TokenWithPrices | undefined) => void; - setSellToken: (token: TokenWithPrices | undefined) => void; - onSwap: (quote: Buy.quote.Result | Sell.quote.Result) => void; + onSwap: ( + quote: Buy.quote.Result | Sell.quote.Result, + selection: { + buyToken: TokenWithPrices; + sellToken: TokenWithPrices; + }, + ) => void; }; export function SwapUI(props: SwapUIProps) { @@ -52,6 +54,13 @@ export function SwapUI(props: SwapUIProps) { "base" | "select-buy-token" | "select-sell-ui" >("base"); + const [buyToken, setBuyToken] = useState( + undefined, + ); + const [sellToken, setSellToken] = useState( + undefined, + ); + if (screen === "base") { return ( { setScreen(type === "buy" ? "select-buy-token" : "select-sell-ui"); }} + buyToken={buyToken} + sellToken={sellToken} + setBuyToken={setBuyToken} + setSellToken={setSellToken} /> ); } @@ -68,9 +81,9 @@ export function SwapUI(props: SwapUIProps) { setScreen("base")} client={props.client} - selectedToken={props.buyToken} + selectedToken={buyToken} setSelectedToken={(token) => { - props.setBuyToken(token); + setBuyToken(token); setScreen("base"); }} /> @@ -82,9 +95,9 @@ export function SwapUI(props: SwapUIProps) { setScreen("base")} client={props.client} - selectedToken={props.sellToken} + selectedToken={sellToken} setSelectedToken={(token) => { - props.setSellToken(token); + setSellToken(token); setScreen("base"); }} activeWalletInfo={props.activeWalletInfo} @@ -96,7 +109,13 @@ export function SwapUI(props: SwapUIProps) { } export function SwapUIBase( - props: SwapUIProps & { onSelectToken: (type: "buy" | "sell") => void }, + props: SwapUIProps & { + onSelectToken: (type: "buy" | "sell") => void; + buyToken: TokenWithPrices | undefined; + sellToken: TokenWithPrices | undefined; + setBuyToken: (token: TokenWithPrices | undefined) => void; + setSellToken: (token: TokenWithPrices | undefined) => void; + }, ) { const [mode, setMode] = useState<"buy" | "sell">("buy"); const [_buyTokenAmount, setBuyTokenAmount] = useState(""); @@ -242,8 +261,11 @@ export function SwapUIBase( disabled={!preparedResultQuery.data || preparedResultQuery.isFetching} fullWidth onClick={() => { - if (preparedResultQuery.data) { - props.onSwap(preparedResultQuery.data); + if (preparedResultQuery.data && props.buyToken && props.sellToken) { + props.onSwap(preparedResultQuery.data, { + buyToken: props.buyToken, + sellToken: props.sellToken, + }); } }} style={{ From 751bd0a7db6035d2cd760ac9ccc20a4d845b1634 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Sat, 13 Sep 2025 04:07:53 +0530 Subject: [PATCH 03/36] add success and error screens --- .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 83 ++++++++++++++++--- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 5cf25de137d..965661ae6ce 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import type { Buy, Sell } from "../../../../../bridge/index.js"; import type { TokenWithPrices } from "../../../../../bridge/types/Token.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; @@ -12,11 +12,14 @@ import type { BridgePrepareRequest, BridgePrepareResult, } from "../../../../core/hooks/useBridgePrepare.js"; +import type { CompletedStatusResult } from "../../../../core/hooks/useStepExecutor.js"; import { webWindowAdapter } from "../../../adapters/WindowAdapter.js"; import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js"; import { DynamicHeight } from "../../components/DynamicHeight.js"; import type { LocaleId } from "../../types.js"; +import { ErrorBanner } from "../ErrorBanner.js"; import { PaymentDetails } from "../payment-details/PaymentDetails.js"; +import { SuccessScreen } from "../payment-success/SuccessScreen.js"; import { QuoteLoader } from "../QuoteLoader.js"; import { StepRunner } from "../StepRunner.js"; import { useActiveWalletInfo } from "./hooks.js"; @@ -102,6 +105,17 @@ type SwapWidgetScreen = preparedQuote: BridgePrepareResult; buyToken: TokenWithPrices; sellToken: TokenWithPrices; + } + | { + id: "5:success"; + completedStatuses: CompletedStatusResult[]; + preparedQuote: BridgePrepareResult; + buyToken: TokenWithPrices; + sellToken: TokenWithPrices; + } + | { + id: "error"; + error: Error; }; function SwapWidgetContent(props: SwapWidgetProps) { @@ -111,6 +125,18 @@ function SwapWidgetContent(props: SwapWidgetProps) { // preload requests useBridgeChains(props.client); + const handleError = useCallback( + (error: Error) => { + console.error(error); + props.onError?.(error); + setScreen({ + id: "error", + error, + }); + }, + [props.onError], + ); + // if wallet suddenly disconnects, show screen 1 if (screen.id === "1:swap-ui" || !activeWalletInfo) { return ( @@ -138,9 +164,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { screen.quote.destinationAmount, screen.buyToken.decimals, )} - onError={() => { - // TODO - }} + onError={handleError} onQuoteReceived={(preparedQuote, request) => { setScreen({ ...screen, @@ -148,7 +172,6 @@ function SwapWidgetContent(props: SwapWidgetProps) { preparedQuote, request, }); - // TODO }} receiver={activeWalletInfo.activeAccount.address} onBack={() => setScreen({ id: "1:swap-ui" })} @@ -183,9 +206,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { id: "4:execute", }); }} - onError={(_error) => { - // TODO - }} + onError={handleError} paymentMethod={{ quote: screen.quote, type: "wallet", @@ -214,11 +235,13 @@ function SwapWidgetContent(props: SwapWidgetProps) { id: "3:preview", }); }} - onCancel={() => { - // TODO - }} - onComplete={() => { - // TODO + onCancel={props.onCancel} + onComplete={(completedStatuses) => { + setScreen({ + ...screen, + id: "5:success", + completedStatuses, + }); }} request={screen.request} wallet={activeWalletInfo.activeWallet} @@ -227,5 +250,39 @@ function SwapWidgetContent(props: SwapWidgetProps) { ); } + if (screen.id === "5:success") { + return ( + { + setScreen({ id: "1:swap-ui" }); + }} + preparedQuote={screen.preparedQuote} + uiOptions={{ + destinationToken: screen.buyToken, + mode: "fund_wallet", + currency: props.currency, + }} + windowAdapter={webWindowAdapter} + hasPaymentId={false} // TODO Question: Do we need to expose this as prop? + /> + ); + } + + if (screen.id === "error") { + { + setScreen({ id: "1:swap-ui" }); + props.onCancel?.(); + }} + onRetry={() => { + setScreen({ id: "1:swap-ui" }); + }} + />; + } + return null; } From ce099a3ce512cf47b986a26e345c6cce3e613ef8 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Sat, 13 Sep 2025 04:39:22 +0530 Subject: [PATCH 04/36] resolve todo --- .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 8 +++- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 38 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 965661ae6ce..1b15ad5171f 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -89,6 +89,7 @@ type SwapWidgetScreen = quote: Buy.quote.Result | Sell.quote.Result; buyToken: TokenWithPrices; sellToken: TokenWithPrices; + sellTokenBalance: bigint; } | { id: "3:preview"; @@ -97,6 +98,7 @@ type SwapWidgetScreen = quote: Buy.quote.Result | Sell.quote.Result; buyToken: TokenWithPrices; sellToken: TokenWithPrices; + sellTokenBalance: bigint; } | { id: "4:execute"; @@ -105,6 +107,7 @@ type SwapWidgetScreen = preparedQuote: BridgePrepareResult; buyToken: TokenWithPrices; sellToken: TokenWithPrices; + sellTokenBalance: bigint; } | { id: "5:success"; @@ -186,7 +189,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { quote: screen.quote, type: "wallet", payerWallet: activeWalletInfo.activeWallet, - balance: 0n, // TODO - what is this? + balance: screen.sellTokenBalance, originToken: screen.sellToken, }} /> @@ -211,7 +214,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { quote: screen.quote, type: "wallet", payerWallet: activeWalletInfo.activeWallet, - balance: 0n, // TODO - what is this? + balance: screen.sellTokenBalance, originToken: screen.sellToken, }} preparedQuote={screen.preparedQuote} @@ -233,6 +236,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { setScreen({ ...screen, id: "3:preview", + sellTokenBalance: screen.sellTokenBalance, }); }} onCancel={props.onCancel} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 77f9ace0ea4..c9bfaa6e817 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -5,11 +5,14 @@ import { useState } from "react"; import { Buy, Sell } from "../../../../../bridge/index.js"; import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; import type { TokenWithPrices } from "../../../../../bridge/types/Token.js"; +import { defineChain } from "../../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; import { getFiatSymbol, type SupportedFiatCurrency, } from "../../../../../pay/convert/type.js"; +import { getAddress } from "../../../../../utils/address.js"; import { toTokens, toUnits } from "../../../../../utils/units.js"; import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; import { @@ -19,6 +22,7 @@ import { spacing, type Theme, } from "../../../../core/design-system/index.js"; +import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js"; import { ConnectButton } from "../../ConnectWallet/ConnectButton.js"; import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js"; import { Container } from "../../components/basic.js"; @@ -44,6 +48,7 @@ type SwapUIProps = { quote: Buy.quote.Result | Sell.quote.Result, selection: { buyToken: TokenWithPrices; + sellTokenBalance: bigint; sellToken: TokenWithPrices; }, ) => void; @@ -193,6 +198,13 @@ export function SwapUIBase( const isBuyAmountFetching = mode === "sell" && preparedResultQuery.isFetching; const isSellAmountFetching = mode === "buy" && preparedResultQuery.isFetching; + const sellTokenBalanceQuery = useTokenBalance({ + chainId: props.sellToken?.chainId, + tokenAddress: props.sellToken?.address, + client: props.client, + walletAddress: props.activeWalletInfo?.activeAccount.address, + }); + return ( {/* Sell */} @@ -261,10 +273,16 @@ export function SwapUIBase( disabled={!preparedResultQuery.data || preparedResultQuery.isFetching} fullWidth onClick={() => { - if (preparedResultQuery.data && props.buyToken && props.sellToken) { + if ( + preparedResultQuery.data && + props.buyToken && + props.sellToken && + sellTokenBalanceQuery.data + ) { props.onSwap(preparedResultQuery.data, { buyToken: props.buyToken, sellToken: props.sellToken, + sellTokenBalance: sellTokenBalanceQuery.data.value, }); } }} @@ -561,3 +579,21 @@ const SwitchButtonInner = /* @__PURE__ */ styled(Button)(() => { border: `1px solid ${theme.colors.borderColor}`, }; }); + +function useTokenBalance(props: { + chainId: number | undefined; + tokenAddress: string | undefined; + client: ThirdwebClient; + walletAddress: string | undefined; +}) { + return useWalletBalance({ + address: props.walletAddress, + chain: props.chainId ? defineChain(props.chainId) : undefined, + client: props.client, + tokenAddress: props.tokenAddress + ? getAddress(props.tokenAddress) === getAddress(NATIVE_TOKEN_ADDRESS) + ? undefined + : getAddress(props.tokenAddress) + : undefined, + }); +} From ad7daa80c3fb939203f372b04f4463f796b2a160 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Mon, 15 Sep 2025 17:37:54 +0530 Subject: [PATCH 05/36] UI improvements --- .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 23 +- .../Bridge/swap-widget/select-buy-token.tsx | 49 +-- .../Bridge/swap-widget/select-sell-token.tsx | 39 +- .../web/ui/Bridge/swap-widget/storage.ts | 56 +++ .../web/ui/Bridge/swap-widget/swap-ui.tsx | 410 +++++++++++++----- .../react/web/ui/Bridge/swap-widget/types.ts | 5 + .../Bridge/swap-widget/use-bridge-chains.ts | 2 + .../web/ui/Bridge/swap-widget/use-tokens.ts | 2 + .../react/web/ui/Bridge/swap-widget/utils.ts | 11 + .../src/react/web/ui/components/Img.tsx | 1 + .../Swap/SwapWidget.Prefill.stories.tsx | 116 +++++ 11 files changed, 533 insertions(+), 181 deletions(-) create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/storage.ts create mode 100644 packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.Prefill.stories.tsx diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 1b15ad5171f..f216b1e829a 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -39,6 +39,18 @@ type SwapWidgetProps = { onError?: (error: Error) => void; onCancel?: () => void; connectOptions?: SwapWidgetConnectOptions; + prefill?: { + buyToken?: { + tokenAddress?: string; + chainId: number; + amount?: string; + }; + sellToken?: { + tokenAddress?: string; + chainId: number; + amount?: string; + }; + }; }; export function SwapWidget(props: SwapWidgetProps) { @@ -48,15 +60,7 @@ export function SwapWidget(props: SwapWidgetProps) { style={props.style} className={props.className} > - + ); } @@ -149,6 +153,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { connectOptions={props.connectOptions} currency={props.currency || "USD"} activeWalletInfo={activeWalletInfo} + prefill={props.prefill} onSwap={(quote, selection) => { setScreen({ quote, diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-buy-token.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-buy-token.tsx index cabf9193463..1b0f86cb115 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-buy-token.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-buy-token.tsx @@ -1,9 +1,8 @@ import { CheckIcon, DiscIcon } from "@radix-ui/react-icons"; import { useEffect, useState } from "react"; -import type { Token, TokenWithPrices } from "../../../../../bridge/index.js"; +import type { Token } from "../../../../../bridge/index.js"; import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; -import { getToken } from "../../../../../pay/convert/get-token.js"; import { fontSize, iconSize, @@ -21,14 +20,15 @@ import { Text } from "../../components/text.js"; import { SearchInput } from "./SearchInput.js"; import { SelectChainButton } from "./SelectChainButton.js"; import { SelectBridgeChain } from "./select-chain.js"; +import type { TokenSelection } from "./types.js"; import { useBridgeChains } from "./use-bridge-chains.js"; import { useTokens } from "./use-tokens.js"; type SelectBuyTokenProps = { onBack: () => void; client: ThirdwebClient; - selectedToken: TokenWithPrices | undefined; - setSelectedToken: (token: TokenWithPrices) => void; + selectedToken: TokenSelection | undefined; + setSelectedToken: (token: TokenSelection) => void; }; function getDefaultSelectedChain( @@ -110,8 +110,8 @@ export function SelectBuyTokenUI( setSelectedChain: (chain: BridgeChain) => void; search: string; setSearch: (search: string) => void; - selectedToken: TokenWithPrices | undefined; - setSelectedToken: (token: TokenWithPrices) => void; + selectedToken: TokenSelection | undefined; + setSelectedToken: (token: TokenSelection) => void; showMore: (() => void) | undefined; }, ) { @@ -178,7 +178,9 @@ export function SelectBuyTokenUI( token={token} client={props.client} onSelect={props.setSelectedToken} - isSelected={props.selectedToken?.address === token.address} + isSelected={ + props.selectedToken?.tokenAddress === token.address + } /> ))} @@ -242,10 +244,9 @@ export function SelectBuyTokenUI( function TokenButton(props: { token: Token; client: ThirdwebClient; - onSelect: (tokenWithPrices: TokenWithPrices) => void; + onSelect: (tokenWithPrices: TokenSelection) => void; isSelected: boolean; }) { - const [isLoading, setIsLoading] = useState(false); return (
- {isLoading ? ( - - ) : ( - props.isSelected && ( - - ) )} ); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx index c4ac1762c58..d982c9ace07 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx @@ -4,10 +4,8 @@ import { MagnifyingGlassIcon, } from "@radix-ui/react-icons"; import { useEffect, useState } from "react"; -import type { TokenWithPrices } from "../../../../../bridge/index.js"; import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; -import { getToken } from "../../../../../pay/convert/get-token.js"; import { toTokens } from "../../../../../utils/units.js"; import { fontSize, @@ -27,15 +25,15 @@ import { Spinner } from "../../components/Spinner.js"; import { Text } from "../../components/text.js"; import { SelectChainButton } from "./SelectChainButton.js"; import { SelectBridgeChain } from "./select-chain.js"; -import type { ActiveWalletInfo } from "./types.js"; +import type { ActiveWalletInfo, TokenSelection } from "./types.js"; import { useBridgeChains } from "./use-bridge-chains.js"; import { type TokenBalance, useTokenBalances } from "./use-tokens.js"; type SelectSellTokenProps = { onBack: () => void; client: ThirdwebClient; - selectedToken: TokenWithPrices | undefined; - setSelectedToken: (token: TokenWithPrices) => void; + selectedToken: TokenSelection | undefined; + setSelectedToken: (token: TokenSelection) => void; }; function getDefaultSelectedChain( @@ -196,8 +194,8 @@ export function SelectSellTokenConnectedUI( setSelectedChain: (chain: BridgeChain) => void; search: string; setSearch: (search: string) => void; - selectedToken: TokenWithPrices | undefined; - setSelectedToken: (token: TokenWithPrices) => void; + selectedToken: TokenSelection | undefined; + setSelectedToken: (token: TokenSelection) => void; showAll: (() => void) | undefined; }, ) { @@ -296,7 +294,7 @@ export function SelectSellTokenConnectedUI( onSelect={props.setSelectedToken} isSelected={ props.selectedToken - ? props.selectedToken.address.toLowerCase() === + ? props.selectedToken.tokenAddress?.toLowerCase() === token.token_address.toLowerCase() : false } @@ -364,10 +362,9 @@ export function SelectSellTokenConnectedUI( function TokenButton(props: { token: TokenBalance; client: ThirdwebClient; - onSelect: (tokenWithPrices: TokenWithPrices) => void; + onSelect: (tokenWithPrices: TokenSelection) => void; isSelected: boolean; }) { - const [isLoading, setIsLoading] = useState(false); const tokenBalanceInUnits = toTokens( BigInt(props.token.balance), props.token.decimals, @@ -391,14 +388,10 @@ function TokenButton(props: { }} gap="sm" onClick={async () => { - setIsLoading(true); - const tokenWithPrices = await getToken( - props.client, - props.token.token_address, - props.token.chain_id, - ); - setIsLoading(false); - props.onSelect(tokenWithPrices); + props.onSelect({ + tokenAddress: props.token.token_address, + chainId: props.token.chain_id, + }); }} > - - - {props.token.symbol} - - - {isLoading && } - + + {props.token.symbol} +
{formatTokenAmount( BigInt(props.token.balance), diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/storage.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/storage.ts new file mode 100644 index 00000000000..28bce3ad55a --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/storage.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +const tokenSelectionSchema = z.object({ + tokenAddress: z.string().min(1), + chainId: z.number().int().positive(), +}); + +const lastUsedTokensSchema = z.object({ + buyToken: tokenSelectionSchema.optional(), + sellToken: tokenSelectionSchema.optional(), +}); + +type LastUsedTokens = z.infer; + +const STORAGE_KEY = "tw.swap.lastUsedTokens"; + +function isBrowser() { + return ( + typeof window !== "undefined" && typeof window.localStorage !== "undefined" + ); +} + +export function getLastUsedTokens(): LastUsedTokens | undefined { + if (!isBrowser()) { + return undefined; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return undefined; + } + const parsed = JSON.parse(raw); + const result = lastUsedTokensSchema.safeParse(parsed); + if (!result.success) { + return undefined; + } + return result.data; + } catch { + return undefined; + } +} + +export function setLastUsedTokens(update: LastUsedTokens): void { + if (!isBrowser()) { + return; + } + try { + const result = lastUsedTokensSchema.safeParse(update); + if (!result.success) { + return; + } + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(result.data)); + } catch { + // ignore write errors + } +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index c9bfaa6e817..29a9fafefca 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -1,13 +1,14 @@ import styled from "@emotion/styled"; import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Buy, Sell } from "../../../../../bridge/index.js"; import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; import type { TokenWithPrices } from "../../../../../bridge/types/Token.js"; import { defineChain } from "../../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { getToken } from "../../../../../pay/convert/get-token.js"; import { getFiatSymbol, type SupportedFiatCurrency, @@ -34,7 +35,12 @@ import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; import { SelectBuyToken } from "./select-buy-token.js"; import { SelectSellToken } from "./select-sell-token.js"; -import type { ActiveWalletInfo, SwapWidgetConnectOptions } from "./types.js"; +import { getLastUsedTokens, setLastUsedTokens } from "./storage.js"; +import type { + ActiveWalletInfo, + SwapWidgetConnectOptions, + TokenSelection, +} from "./types.js"; import { useBridgeChains } from "./use-bridge-chains.js"; import { cleanedChainName } from "./utils.js"; @@ -52,6 +58,20 @@ type SwapUIProps = { sellToken: TokenWithPrices; }, ) => void; + prefill: + | { + buyToken?: { + tokenAddress?: string; + chainId: number; + amount?: string; + }; + sellToken?: { + tokenAddress?: string; + chainId: number; + amount?: string; + }; + } + | undefined; }; export function SwapUI(props: SwapUIProps) { @@ -59,12 +79,73 @@ export function SwapUI(props: SwapUIProps) { "base" | "select-buy-token" | "select-sell-ui" >("base"); - const [buyToken, setBuyToken] = useState( - undefined, - ); - const [sellToken, setSellToken] = useState( - undefined, - ); + const [amountSelection, setAmountSelection] = useState<{ + type: "buy" | "sell"; + amount: string; + }>(() => { + if (props.prefill?.buyToken?.amount) { + return { + type: "buy", + amount: props.prefill.buyToken.amount, + }; + } + if (props.prefill?.sellToken?.amount) { + return { + type: "sell", + amount: props.prefill.sellToken.amount, + }; + } + return { + type: "buy", + amount: "", + }; + }); + + const [buyToken, setBuyToken] = useState(() => { + if (props.prefill?.buyToken) { + return { + tokenAddress: + props.prefill.buyToken.tokenAddress || + getAddress(NATIVE_TOKEN_ADDRESS), + chainId: props.prefill.buyToken.chainId, + }; + } + const last = getLastUsedTokens()?.buyToken; + if (last) { + return { + tokenAddress: getAddress(last.tokenAddress), + chainId: last.chainId, + }; + } + return undefined; + }); + const [sellToken, setSellToken] = useState(() => { + if (props.prefill?.sellToken) { + return { + tokenAddress: + props.prefill.sellToken.tokenAddress || + getAddress(NATIVE_TOKEN_ADDRESS), + chainId: props.prefill.sellToken.chainId, + }; + } + const last = getLastUsedTokens()?.sellToken; + if (last) { + return { + tokenAddress: getAddress(last.tokenAddress), + chainId: last.chainId, + }; + } + return undefined; + }); + + // persist selections to localStorage whenever they change + useEffect(() => { + if (buyToken) { + setLastUsedTokens({ buyToken, sellToken }); + } + }, [buyToken, sellToken]); + + console.log("prefill", props.prefill); if (screen === "base") { return ( @@ -77,6 +158,8 @@ export function SwapUI(props: SwapUIProps) { sellToken={sellToken} setBuyToken={setBuyToken} setSellToken={setSellToken} + amountSelection={amountSelection} + setAmountSelection={setAmountSelection} /> ); } @@ -113,59 +196,106 @@ export function SwapUI(props: SwapUIProps) { return null; } +function useTokenPrice(options: { + token: TokenSelection | undefined; + client: ThirdwebClient; +}) { + return useQuery({ + queryKey: ["token-price", options.token], + enabled: !!options.token, + queryFn: () => { + if (!options.token) { + throw new Error("Token is required"); + } + return getToken( + options.client, + options.token.tokenAddress, + options.token.chainId, + ); + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +} + export function SwapUIBase( props: SwapUIProps & { onSelectToken: (type: "buy" | "sell") => void; - buyToken: TokenWithPrices | undefined; - sellToken: TokenWithPrices | undefined; - setBuyToken: (token: TokenWithPrices | undefined) => void; - setSellToken: (token: TokenWithPrices | undefined) => void; + buyToken: TokenSelection | undefined; + sellToken: TokenSelection | undefined; + setBuyToken: (token: TokenSelection | undefined) => void; + setSellToken: (token: TokenSelection | undefined) => void; + amountSelection: { + type: "buy" | "sell"; + amount: string; + }; + setAmountSelection: (amountSelection: { + type: "buy" | "sell"; + amount: string; + }) => void; }, ) { - const [mode, setMode] = useState<"buy" | "sell">("buy"); - const [_buyTokenAmount, setBuyTokenAmount] = useState(""); - const [_sellTokenAmount, setSellTokenAmount] = useState(""); + const buyTokenQuery = useTokenPrice({ + token: props.buyToken, + client: props.client, + }); + const sellTokenQuery = useTokenPrice({ + token: props.sellToken, + client: props.client, + }); + + const buyTokenWithPrices = buyTokenQuery.data; + const sellTokenWithPrices = sellTokenQuery.data; const preparedResultQuery = useQuery({ queryKey: [ "swap-quote", - mode, - props.buyToken, - props.sellToken, - _sellTokenAmount, - _buyTokenAmount, + props.amountSelection, + buyTokenWithPrices, + sellTokenWithPrices, ], enabled: - !!props.buyToken && - !!props.sellToken && - (mode === "buy" ? !!_buyTokenAmount : !!_sellTokenAmount), + !!buyTokenWithPrices && + !!sellTokenWithPrices && + !!props.amountSelection.amount, queryFn: async () => { - if (!props.buyToken || !props.sellToken || !props.activeWalletInfo) { - return; + if ( + !buyTokenWithPrices || + !sellTokenWithPrices || + !props.activeWalletInfo || + !props.amountSelection.amount + ) { + throw new Error("Invalid state"); } - if (mode === "buy" && _buyTokenAmount) { + if (props.amountSelection.type === "buy") { const res = await Buy.quote({ - buyAmountWei: toUnits(_buyTokenAmount, props.buyToken.decimals), + buyAmountWei: toUnits( + props.amountSelection.amount, + buyTokenWithPrices.decimals, + ), // origin = sell - originChainId: props.sellToken.chainId, - originTokenAddress: props.sellToken.address, + originChainId: sellTokenWithPrices.chainId, + originTokenAddress: sellTokenWithPrices.address, // destination = buy - destinationChainId: props.buyToken.chainId, - destinationTokenAddress: props.buyToken.address, + destinationChainId: buyTokenWithPrices.chainId, + destinationTokenAddress: buyTokenWithPrices.address, client: props.client, }); return res; - } else if (mode === "sell" && _sellTokenAmount) { + } else if (props.amountSelection.type === "sell") { const res = await Sell.prepare({ - amount: toUnits(_sellTokenAmount, props.sellToken.decimals), + amount: toUnits( + props.amountSelection.amount, + sellTokenWithPrices.decimals, + ), // origin = sell - originChainId: props.sellToken.chainId, - originTokenAddress: props.sellToken.address, + originChainId: sellTokenWithPrices.chainId, + originTokenAddress: sellTokenWithPrices.address, // destination = buy - destinationChainId: props.buyToken.chainId, - destinationTokenAddress: props.buyToken.address, + destinationChainId: buyTokenWithPrices.chainId, + destinationTokenAddress: buyTokenWithPrices.address, client: props.client, receiver: props.activeWalletInfo.activeAccount.address, sender: props.activeWalletInfo.activeAccount.address, @@ -180,27 +310,38 @@ export function SwapUIBase( }); const sellTokenAmount = - preparedResultQuery.data && mode === "buy" && props.sellToken - ? toTokens( - preparedResultQuery.data.originAmount, - props.sellToken.decimals, - ) - : _sellTokenAmount; + props.amountSelection.type === "sell" + ? props.amountSelection.amount + : preparedResultQuery.data && + props.amountSelection.type === "buy" && + sellTokenWithPrices + ? toTokens( + preparedResultQuery.data.originAmount, + sellTokenWithPrices.decimals, + ) + : ""; const buyTokenAmount = - preparedResultQuery.data && mode === "sell" && props.buyToken - ? toTokens( - preparedResultQuery.data.destinationAmount, - props.buyToken.decimals, - ) - : _buyTokenAmount; - - const isBuyAmountFetching = mode === "sell" && preparedResultQuery.isFetching; - const isSellAmountFetching = mode === "buy" && preparedResultQuery.isFetching; + props.amountSelection.type === "buy" + ? props.amountSelection.amount + : preparedResultQuery.data && + props.amountSelection.type === "sell" && + buyTokenWithPrices + ? toTokens( + preparedResultQuery.data.destinationAmount, + buyTokenWithPrices.decimals, + ) + : ""; + + // when buy amount is set, the sell amount is fetched + const isBuyAmountFetching = + props.amountSelection.type === "sell" && preparedResultQuery.isFetching; + const isSellAmountFetching = + props.amountSelection.type === "buy" && preparedResultQuery.isFetching; const sellTokenBalanceQuery = useTokenBalance({ - chainId: props.sellToken?.chainId, - tokenAddress: props.sellToken?.address, + chainId: sellTokenWithPrices?.chainId, + tokenAddress: sellTokenWithPrices?.address, client: props.client, walletAddress: props.activeWalletInfo?.activeAccount.address, }); @@ -209,15 +350,22 @@ export function SwapUIBase( {/* Sell */} { - setSellTokenAmount(value); - setBuyTokenAmount(""); - setMode("sell"); + props.setAmountSelection({ type: "sell", amount: value }); }} - selectedToken={props.sellToken} + selectedToken={ + props.sellToken + ? { + data: sellTokenQuery.data, + isFetching: sellTokenQuery.isFetching, + } + : undefined + } client={props.client} currency={props.currency} onSelectToken={() => props.onSelectToken("sell")} @@ -230,22 +378,26 @@ export function SwapUIBase( const temp = props.sellToken; props.setSellToken(props.buyToken); props.setBuyToken(temp); - // reset amounts - setSellTokenAmount(""); - setBuyTokenAmount(""); }} /> {/* Buy */} { - setBuyTokenAmount(value); - setSellTokenAmount(""); - setMode("buy"); + props.setAmountSelection({ type: "buy", amount: value }); }} client={props.client} currency={props.currency} @@ -275,13 +427,13 @@ export function SwapUIBase( onClick={() => { if ( preparedResultQuery.data && - props.buyToken && - props.sellToken && + buyTokenWithPrices && + sellTokenWithPrices && sellTokenBalanceQuery.data ) { props.onSwap(preparedResultQuery.data, { - buyToken: props.buyToken, - sellToken: props.sellToken, + buyToken: buyTokenWithPrices, + sellToken: sellTokenWithPrices, sellTokenBalance: sellTokenBalanceQuery.data.value, }); } @@ -360,24 +512,31 @@ function DecimalInput(props: { function TokenSection(props: { label: string; - amount: string; - isPending: boolean; + amount: { + data: string; + isFetching: boolean; + }; setAmount: (amount: string) => void; - selectedToken: TokenWithPrices | undefined; + selectedToken: + | { + data: TokenWithPrices | undefined; + isFetching: boolean; + } + | undefined; currency: SupportedFiatCurrency; onSelectToken: () => void; client: ThirdwebClient; }) { const chainQuery = useBridgeChains(props.client); const chain = chainQuery.data?.find( - (chain) => chain.chainId === props.selectedToken?.chainId, + (chain) => chain.chainId === props.selectedToken?.data?.chainId, ); - const fiatPricePerToken = props.selectedToken?.prices[props.currency]; - const totalFiatValue = !props.amount + const fiatPricePerToken = props.selectedToken?.data?.prices[props.currency]; + const totalFiatValue = !props.amount.data ? undefined : fiatPricePerToken - ? fiatPricePerToken * Number(props.amount) + ? fiatPricePerToken * Number(props.amount.data) : undefined; return ( @@ -401,10 +560,10 @@ function TokenSection(props: { gap: spacing.sm, }} > - {props.isPending ? ( + {props.amount.isFetching ? ( ) : ( - + )} {!props.selectedToken ? ( @@ -434,23 +593,32 @@ function TokenSection(props: { {/* Fiat Value */}
- - {getFiatSymbol(props.currency)} - - {totalFiatValue === undefined - ? "0.00" - : totalFiatValue < 0.01 - ? "~0.00" - : totalFiatValue.toFixed(2)} + {getFiatSymbol(props.currency)} + {props.amount.isFetching ? ( + + ) : ( + + {totalFiatValue === undefined + ? "0.00" + : totalFiatValue < 0.01 + ? "~0.00" + : totalFiatValue.toFixed(2)} + + )}
); } function SelectedTokenButton(props: { - selectedToken: TokenWithPrices; + selectedToken: + | { + data: TokenWithPrices | undefined; + isFetching: boolean; + } + | undefined; client: ThirdwebClient; onSelectToken: () => void; chain: BridgeChain | undefined; @@ -474,7 +642,11 @@ function SelectedTokenButton(props: { > {/* token icon */} {/* chain icon */} - {props.chain && ( - + - - - )} + /> +
{/* token symbol and chain name */} - - {props.selectedToken.symbol} - - {props.chain && ( + {props.selectedToken?.isFetching ? ( + + ) : ( + + {props.selectedToken?.data?.symbol} + + )} + + {props.chain ? ( {cleanedChainName(props.chain.name)} + ) : ( + )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts index 858d4aba64d..f0c49032d51 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts @@ -139,3 +139,8 @@ export type ActiveWalletInfo = { activeWallet: Wallet; activeAccount: Account; }; + +export type TokenSelection = { + tokenAddress: string; + chainId: number; +}; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts index f7eb7ecf2d3..27547d7d35f 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-bridge-chains.ts @@ -8,5 +8,7 @@ export function useBridgeChains(client: ThirdwebClient) { queryFn: () => { return chains({ client }); }, + refetchOnMount: false, + refetchOnWindowFocus: false, }); } diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts index 3960e21e971..d4e9d01263a 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts @@ -111,5 +111,7 @@ export function useTokenBalances(options: { const json = (await response.json()) as TokenBalancesResponse; return json.result; }, + refetchOnMount: false, + refetchOnWindowFocus: false, }); } diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts index a92d79e481d..dc7679469dc 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts @@ -1,3 +1,14 @@ +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { getAddress } from "../../../../../utils/address.js"; +import type { TokenSelection } from "./types.js"; + export function cleanedChainName(name: string) { return name.replace("Mainnet", ""); } + +export function isTokenSelectionNativeToken(token: TokenSelection) { + if (!token.tokenAddress) { + return true; + } + return getAddress(token.tokenAddress) === getAddress(NATIVE_TOKEN_ADDRESS); +} diff --git a/packages/thirdweb/src/react/web/ui/components/Img.tsx b/packages/thirdweb/src/react/web/ui/components/Img.tsx index 398313c9eaf..8abfdf44e41 100644 --- a/packages/thirdweb/src/react/web/ui/components/Img.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Img.tsx @@ -77,6 +77,7 @@ export const Img: React.FC<{ borderRadius: radius.md, borderWidth: "1px", borderStyle: "solid", + ...props.style, }} >
diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.Prefill.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.Prefill.stories.tsx new file mode 100644 index 00000000000..bf2e071fc58 --- /dev/null +++ b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.Prefill.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta } from "@storybook/react-vite"; +import { NATIVE_TOKEN_ADDRESS } from "../../../constants/addresses.js"; +import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; +import { storyClient } from "../../utils.js"; + +const meta = { + parameters: { + layout: "centered", + }, + title: "Bridge/Swap/SwapWidget/Prefill", +} satisfies Meta; +export default meta; + +export function Buy_NativeToken() { + return ( + + ); +} + +export function Buy_Base_USDC() { + return ( + + ); +} + +export function Buy_NativeToken_With_Amount() { + return ( + + ); +} + +export function Sell_NativeToken() { + return ( + + ); +} + +export function Sell_Base_USDC() { + return ( + + ); +} + +export function Sell_NativeToken_With_Amount() { + return ( + + ); +} + +export function Buy_And_Sell_NativeToken() { + return ( + + ); +} From 0b70ce85f31e96f6cc7e05114b8b71fc32e3afa9 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Mon, 15 Sep 2025 17:42:46 +0530 Subject: [PATCH 06/36] Fix theme in select-sell ui --- .../Bridge/swap-widget/select-sell-token.tsx | 5 +++++ .../web/ui/Bridge/swap-widget/swap-ui.tsx | 1 + .../Bridge/Swap/SelectSellToken.stories.tsx | 6 +++++- .../Bridge/Swap/SwapWidget.stories.tsx | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx index d982c9ace07..f38c65894ef 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx @@ -12,6 +12,7 @@ import { iconSize, radius, spacing, + type Theme, } from "../../../../core/design-system/index.js"; import { ConnectButton } from "../../ConnectWallet/ConnectButton.js"; import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js"; @@ -46,6 +47,7 @@ function getDefaultSelectedChain( export function SelectSellToken( props: SelectSellTokenProps & { activeWalletInfo: ActiveWalletInfo | undefined; + theme: Theme | "light" | "dark"; }, ) { if (props.activeWalletInfo) { @@ -60,6 +62,7 @@ export function SelectSellToken( ); } @@ -67,6 +70,7 @@ export function SelectSellToken( export function SelectSellTokenDisconnectedUI(props: { onBack: () => void; client: ThirdwebClient; + theme: Theme | "light" | "dark"; }) { return ( @@ -111,6 +115,7 @@ export function SelectSellTokenDisconnectedUI(props: { diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 29a9fafefca..a09f01ddb7d 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -184,6 +184,7 @@ export function SwapUI(props: SwapUIProps) { onBack={() => setScreen("base")} client={props.client} selectedToken={sellToken} + theme={props.theme} setSelectedToken={(token) => { setSellToken(token); setScreen("base"); diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx index d31a53f58cf..9c1b82b96b7 100644 --- a/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx @@ -72,7 +72,11 @@ export function ChainLoading() { export function Disconnected() { return ( - {}} /> + {}} + theme="dark" + /> ); } diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx index 5b26563f9ca..9b53ac40e95 100644 --- a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx @@ -1,4 +1,5 @@ import type { Meta } from "@storybook/react-vite"; +import { lightTheme } from "../../../react/core/design-system/index.js"; import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; import { storyClient } from "../../utils.js"; @@ -21,3 +22,21 @@ export function CurrencySet() { export function LightMode() { return ; } + +export function CustomTheme() { + return ( + + ); +} From dbba64deb22bd081af9ffd75aa377074deb232c4 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Mon, 15 Sep 2025 17:58:35 +0530 Subject: [PATCH 07/36] add JSdoc, export component --- packages/thirdweb/src/exports/react.ts | 4 + .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 112 +++++++++++++++++- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 9 ++ .../Bridge/Swap/SwapWidget.stories.tsx | 10 ++ 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index c267cd95716..fdd9b8bfb1f 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -141,6 +141,10 @@ export { CheckoutWidget, type CheckoutWidgetProps, } from "../react/web/ui/Bridge/CheckoutWidget.js"; +export { + SwapWidget, + type SwapWidgetProps, +} from "../react/web/ui/Bridge/swap-widget/SwapWidget.js"; export { TransactionWidget, type TransactionWidgetProps, diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index f216b1e829a..a42b001877f 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -16,7 +16,6 @@ import type { CompletedStatusResult } from "../../../../core/hooks/useStepExecut import { webWindowAdapter } from "../../../adapters/WindowAdapter.js"; import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js"; import { DynamicHeight } from "../../components/DynamicHeight.js"; -import type { LocaleId } from "../../types.js"; import { ErrorBanner } from "../ErrorBanner.js"; import { PaymentDetails } from "../payment-details/PaymentDetails.js"; import { SuccessScreen } from "../payment-success/SuccessScreen.js"; @@ -27,18 +26,120 @@ import { SwapUI } from "./swap-ui.js"; import type { SwapWidgetConnectOptions } from "./types.js"; import { useBridgeChains } from "./use-bridge-chains.js"; -type SwapWidgetProps = { +export type SwapWidgetProps = { + /** + * A client is the entry point to the thirdweb SDK. + * It is required for all other actions. + * You can create a client using the `createThirdwebClient` function. Refer to the [Creating a Client](https://portal.thirdweb.com/typescript/v5/client) documentation for more information. + * + * You must provide a `clientId` or `secretKey` in order to initialize a client. Pass `clientId` if you want for client-side usage and `secretKey` for server-side usage. + * + * ```tsx + * import { createThirdwebClient } from "thirdweb"; + * + * const client = createThirdwebClient({ + * clientId: "", + * }) + * ``` + */ client: ThirdwebClient; + /** + * Set the theme for the `SwapWidget` component. By default it is set to `"dark"` + * + * theme can be set to either `"dark"`, `"light"` or a custom theme object. + * You can also import [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme) + * or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme) + * functions from `thirdweb/react` to use the default themes as base and overrides parts of it. + * @example + * ```ts + * import { lightTheme } from "thirdweb/react"; + * + * const customTheme = lightTheme({ + * colors: { + * modalBg: 'red' + * } + * }) + * + * function Example() { + * return + * } + * ``` + */ theme?: "light" | "dark" | Theme; className?: string; - locale?: LocaleId; + /** + * The currency to use for the payment. + * @default "USD" + */ currency?: SupportedFiatCurrency; style?: React.CSSProperties; + /** + * Whether to show thirdweb branding in the widget. + * @default true + */ showThirdwebBranding?: boolean; + /** + * Callback to be called when the swap is successful. + */ onSuccess?: () => void; + /** + * Callback to be called when user encounters an error when swapping. + */ onError?: (error: Error) => void; + /** + * Callback to be called when the user cancels the purchase. + */ onCancel?: () => void; connectOptions?: SwapWidgetConnectOptions; + /** + * The prefill Buy and/or Sell tokens for the swap widget. If `tokenAddress` is not provided, the native token will be used + * + * @example + * + * ### Set an ERC20 token as the buy token + * ```ts + * + * ``` + * + * ### Set a native token as the sell token + * + * ```ts + * + * ``` + * + * ### Set 0.1 Base USDC as the buy token + * ```ts + * + * ``` + * + * ### Set Base USDC as the buy token and Base native token as the sell token + * ```ts + * + * ``` + */ prefill?: { buyToken?: { tokenAddress?: string; @@ -148,6 +249,11 @@ function SwapWidgetContent(props: SwapWidgetProps) { if (screen.id === "1:swap-ui" || !activeWalletInfo) { return ( )} + + {props.showThirdwebBranding ? ( +
+ + +
+ ) : null} ); } diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx index 9b53ac40e95..2aa71cb9ad9 100644 --- a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx @@ -23,6 +23,16 @@ export function LightMode() { return ; } +export function NoThirdwebBranding() { + return ( + + ); +} + export function CustomTheme() { return ( Date: Mon, 15 Sep 2025 19:03:55 +0530 Subject: [PATCH 08/36] add swap widget in public token page --- .../erc20/_components/PayEmbedSection.tsx | 115 +++++++++++++----- .../src/react/web/ui/Bridge/BuyWidget.tsx | 2 +- .../react/web/ui/Bridge/common/WithHeader.tsx | 34 ++++-- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 23 ++-- .../web/ui/Bridge/swap-widget/use-tokens.ts | 5 +- .../src/react/web/ui/components/Img.tsx | 21 +++- 6 files changed, 145 insertions(+), 55 deletions(-) diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx index b4156ca0430..71eb7f0b8ea 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx @@ -1,12 +1,15 @@ "use client"; import { useTheme } from "next-themes"; +import { useState } from "react"; import type { Chain, ThirdwebClient } from "thirdweb"; -import { BuyWidget } from "thirdweb/react"; +import { BuyWidget, SwapWidget } from "thirdweb/react"; import { reportAssetBuyFailed, reportAssetBuySuccessful, } from "@/analytics/report"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { parseError } from "@/utils/errorParser"; import { getSDKTheme } from "@/utils/sdk-component-theme"; @@ -16,33 +19,89 @@ export function BuyTokenEmbed(props: { tokenAddress: string; }) { const { theme } = useTheme(); + const [tab, setTab] = useState<"buy" | "swap">("swap"); + const themeObj = getSDKTheme(theme === "light" ? "light" : "dark"); return ( - { - const errorMessage = parseError(e); - reportAssetBuyFailed({ - assetType: "coin", - chainId: props.chain.id, - contractType: "DropERC20", - error: errorMessage, - }); - }} - onSuccess={() => { - reportAssetBuySuccessful({ - assetType: "coin", - chainId: props.chain.id, - contractType: "DropERC20", - }); - }} - theme={getSDKTheme(theme === "light" ? "light" : "dark")} - tokenAddress={props.tokenAddress as `0x${string}`} - /> +
+
+ setTab("swap")} + isActive={tab === "swap"} + /> + setTab("buy")} + isActive={tab === "buy"} + /> +
+ + {tab === "buy" && ( + { + const errorMessage = parseError(e); + reportAssetBuyFailed({ + assetType: "coin", + chainId: props.chain.id, + contractType: "DropERC20", + error: errorMessage, + }); + }} + onSuccess={() => { + reportAssetBuySuccessful({ + assetType: "coin", + chainId: props.chain.id, + contractType: "DropERC20", + }); + }} + theme={themeObj} + tokenAddress={props.tokenAddress as `0x${string}`} + /> + )} + + {tab === "swap" && ( + + )} +
+ ); +} + +function TabButton(props: { + label: string; + onClick: () => void; + isActive: boolean; +}) { + return ( + ); } diff --git a/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx index 6f96e71e80e..507a21fa6ef 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx @@ -128,7 +128,7 @@ export type BuyWidgetProps = { amount: string; /** - * The title to display in the widget. + * The title to display in the widget. If `title` is explicity set to an empty string, the title will not be displayed. */ title?: string; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx index 85cb46fdd06..51fb6b7b84c 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx @@ -19,6 +19,8 @@ export function WithHeader({ client: ThirdwebClient; }) { const theme = useCustomTheme(); + const showTitle = uiOptions.metadata?.title !== ""; + return ( {/* image */} @@ -36,25 +38,33 @@ export function WithHeader({ }} /> )} + - {/* title */} - - {uiOptions.metadata?.title || defaultTitle} - - - {/* Description */} - {uiOptions.metadata?.description && ( + {(showTitle || uiOptions.metadata?.description) && ( <> - - - {uiOptions.metadata?.description} - + {/* title */} + {showTitle && ( + + {uiOptions.metadata?.title || defaultTitle} + + )} + + {/* Description */} + {uiOptions.metadata?.description && ( + <> + + + {uiOptions.metadata?.description} + + + )} + + )} - {children} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index ce9ed769eb8..39aa3f57837 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -1,5 +1,9 @@ import styled from "@emotion/styled"; -import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { + ChevronDownIcon, + ChevronRightIcon, + DiscIcon, +} from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { Buy, Sell } from "../../../../../bridge/index.js"; @@ -645,28 +649,26 @@ function SelectedTokenButton(props: { }} > {/* icons */} -
+ {/* token icon */} } style={{ borderRadius: radius.full, }} /> {/* chain icon */} - -
+ {/* token symbol and chain name */} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts index d4e9d01263a..5d39a2ceb79 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts @@ -3,6 +3,7 @@ import type { Token } from "../../../../../bridge/index.js"; import { tokens } from "../../../../../bridge/Token.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; import { isAddress } from "../../../../../utils/address.js"; +import { getThirdwebBaseUrl } from "../../../../../utils/domains.js"; export function useTokens(options: { client: ThirdwebClient; @@ -82,8 +83,10 @@ export function useTokenBalances(options: { if (!options.chainId) { throw new Error("Chain ID is required"); } + const baseUrl = getThirdwebBaseUrl("bridge"); + const isDev = baseUrl.includes("thirdweb-dev"); const url = new URL( - `https://api.thirdweb.com/v1/wallets/${options.walletAddress}/tokens`, + `https://api.${isDev ? "thirdweb-dev" : "thirdweb"}.com/v1/wallets/${options.walletAddress}/tokens`, ); url.searchParams.set("chainId", options.chainId.toString()); url.searchParams.set("limit", options.limit.toString()); diff --git a/packages/thirdweb/src/react/web/ui/components/Img.tsx b/packages/thirdweb/src/react/web/ui/components/Img.tsx index 8abfdf44e41..8689a6a7b21 100644 --- a/packages/thirdweb/src/react/web/ui/components/Img.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Img.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import type { ThirdwebClient } from "../../../../client/client.js"; import { resolveScheme } from "../../../../utils/ipfs.js"; -import { radius } from "../../../core/design-system/index.js"; +import { radius, type Theme } from "../../../core/design-system/index.js"; import { Container } from "./basic.js"; import { Skeleton } from "./Skeleton.js"; @@ -20,6 +20,7 @@ export const Img: React.FC<{ fallbackImage?: string; fallback?: React.ReactNode; client: ThirdwebClient; + skeletonColor?: keyof Theme["colors"]; }> = (props) => { const [_status, setStatus] = useState<"pending" | "fallback" | "loaded">( "pending", @@ -38,7 +39,14 @@ export const Img: React.FC<{ const heightPx = `${props.height || props.width}px`; if (propSrc === undefined) { - return ; + return ( + + ); } const getSrc = () => { @@ -65,7 +73,14 @@ export const Img: React.FC<{ position: "relative", }} > - {status === "pending" && } + {status === "pending" && ( + + )} {status === "fallback" && (props.fallback || ( Date: Mon, 15 Sep 2025 19:18:04 +0530 Subject: [PATCH 09/36] padding changes --- .../thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx | 2 +- .../thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx | 2 +- .../thirdweb/src/react/web/ui/Bridge/FundWallet.tsx | 2 +- .../thirdweb/src/react/web/ui/Bridge/StepRunner.tsx | 2 +- .../src/react/web/ui/Bridge/TransactionPayment.tsx | 2 +- .../src/react/web/ui/Bridge/common/WithHeader.tsx | 10 +++++----- .../web/ui/Bridge/payment-details/PaymentDetails.tsx | 2 +- .../ui/Bridge/payment-selection/PaymentSelection.tsx | 2 +- .../web/ui/Bridge/payment-success/SuccessScreen.tsx | 4 ++-- .../src/react/web/ui/Bridge/swap-widget/swap-ui.tsx | 1 - 10 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx index 21725384b6f..4fd17780466 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx @@ -190,7 +190,7 @@ export function DirectPayment({
) : null} - +
); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx index 1b32798f8a7..cd7ab17e505 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/ErrorBanner.tsx @@ -51,7 +51,7 @@ export function ErrorBanner({ }); return ( - + {/* Error Icon and Message */}
) : null} - + ); } diff --git a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx index de077639b5e..b52b7e4e513 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx @@ -247,7 +247,7 @@ export function StepRunner({ }; return ( - + diff --git a/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx b/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx index a2ed5950409..44973555021 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/TransactionPayment.tsx @@ -431,7 +431,7 @@ export function TransactionPayment({
) : null} - + ); } diff --git a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx index 51fb6b7b84c..053f033f471 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx @@ -39,8 +39,8 @@ export function WithHeader({ /> )} - - + + {(showTitle || uiOptions.metadata?.description) && ( <> @@ -54,14 +54,14 @@ export function WithHeader({ {/* Description */} {uiOptions.metadata?.description && ( <> - - + + {uiOptions.metadata?.description} )} - + )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx index 008495f6f85..ad8f178c487 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx @@ -223,7 +223,7 @@ export function PaymentDetails({ const displayData = getDisplayData(); return ( - + diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx index 1dcf9c302db..3750e449284 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx @@ -268,7 +268,7 @@ export function PaymentSelection({ } return ( - + diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx index 93c06c96b84..2c13c1162be 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx @@ -91,7 +91,7 @@ export function SuccessScreen({ } return ( - + @@ -121,7 +121,7 @@ export function SuccessScreen({ /> - + Payment Successful! diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 39aa3f57837..758470a334a 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -685,7 +685,6 @@ function SelectedTokenButton(props: { client={props.client} width={iconSize.sm} height={iconSize.sm} - skeletonColor="modalBg" style={{ borderRadius: radius.full, }} From a630bc9fa557bfabbb00e434d75d33da34f8c139 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Mon, 15 Sep 2025 19:20:09 +0530 Subject: [PATCH 10/36] remove log --- .../thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 758470a334a..19a85b684c0 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -151,8 +151,6 @@ export function SwapUI(props: SwapUIProps) { } }, [buyToken, sellToken]); - console.log("prefill", props.prefill); - if (screen === "base") { return ( Date: Mon, 15 Sep 2025 19:32:45 +0530 Subject: [PATCH 11/36] fix knip lint --- .../react/web/ui/Bridge/swap-widget/select-chain.tsx | 2 +- .../web/ui/Bridge/swap-widget/select-sell-token.tsx | 2 +- .../src/react/web/ui/Bridge/swap-widget/swap-ui.tsx | 2 +- .../src/react/web/ui/Bridge/swap-widget/use-tokens.ts | 2 +- .../src/react/web/ui/Bridge/swap-widget/utils.ts | 11 ----------- 5 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx index cdcef07f018..7148f4ed09d 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx @@ -133,7 +133,7 @@ function ChainButtonSkeleton() { ); } -export function ChainButton(props: { +function ChainButton(props: { chain: BridgeChain; client: ThirdwebClient; onClick: () => void; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx index f38c65894ef..0745cfecdd8 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx @@ -122,7 +122,7 @@ export function SelectSellTokenDisconnectedUI(props: { ); } -export function SelectSellTokenConnected( +function SelectSellTokenConnected( props: SelectSellTokenProps & { activeWalletInfo: ActiveWalletInfo; }, diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 19a85b684c0..e3b5917f6c1 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -223,7 +223,7 @@ function useTokenPrice(options: { }); } -export function SwapUIBase( +function SwapUIBase( props: SwapUIProps & { onSelectToken: (type: "buy" | "sell") => void; buyToken: TokenSelection | undefined; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts index 5d39a2ceb79..75bb9aeb542 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts @@ -57,7 +57,7 @@ export type TokenBalance = { token_address: string; }; -export type TokenBalancesResponse = { +type TokenBalancesResponse = { result: { pagination: { hasMore: boolean; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts index dc7679469dc..a92d79e481d 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/utils.ts @@ -1,14 +1,3 @@ -import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; -import { getAddress } from "../../../../../utils/address.js"; -import type { TokenSelection } from "./types.js"; - export function cleanedChainName(name: string) { return name.replace("Mainnet", ""); } - -export function isTokenSelectionNativeToken(token: TokenSelection) { - if (!token.tokenAddress) { - return true; - } - return getAddress(token.tokenAddress) === getAddress(NATIVE_TOKEN_ADDRESS); -} From 7681650799ea9df8b44803061fbf3465716f44ad Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Mon, 15 Sep 2025 22:10:49 +0530 Subject: [PATCH 12/36] show balance, show not enough balance error --- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 138 ++++++++++++++++-- .../ui/ConnectWallet/icons/WalletDotIcon.tsx | 19 +++ 2 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 packages/thirdweb/src/react/web/ui/ConnectWallet/icons/WalletDotIcon.tsx diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index e3b5917f6c1..e4d92237217 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -30,7 +30,9 @@ import { import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js"; import { ConnectButton } from "../../ConnectWallet/ConnectButton.js"; import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js"; +import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js"; import { PoweredByThirdweb } from "../../ConnectWallet/PoweredByTW.js"; +import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js"; import { Container } from "../../components/basic.js"; import { Button } from "../../components/buttons.js"; import { Input } from "../../components/formElements.js"; @@ -351,10 +353,34 @@ function SwapUIBase( walletAddress: props.activeWalletInfo?.activeAccount.address, }); + const buyTokenBalanceQuery = useTokenBalance({ + chainId: buyTokenWithPrices?.chainId, + tokenAddress: buyTokenWithPrices?.address, + client: props.client, + walletAddress: props.activeWalletInfo?.activeAccount.address, + }); + + const notEnoughBalance = !!( + props.amountSelection.type === "sell" && + sellTokenBalanceQuery.data?.value && + sellTokenWithPrices?.decimals && + props.amountSelection.amount && + sellTokenBalanceQuery.data.value < + Number( + toUnits(props.amountSelection.amount, sellTokenWithPrices.decimals), + ) + ); + return ( {/* Sell */} ) : ( ); From 8ff84b5ac6dea3c6de84cd82bb50368cb32cbc4d Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Tue, 16 Sep 2025 04:33:21 +0530 Subject: [PATCH 19/36] use quote when wallet is not connected --- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 73 ++++++++++++++++--- 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 254d3cfe30c..c999d1a2005 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -191,25 +191,73 @@ function SwapUIBase( props.amountSelection, buyTokenWithPrices, sellTokenWithPrices, + props.activeWalletInfo?.activeAccount.address, ], enabled: !!buyTokenWithPrices && !!sellTokenWithPrices && - !!props.amountSelection.amount && - !!props.activeWalletInfo, - queryFn: async (): Promise<{ - result: Extract; - request: Extract; - }> => { + !!props.amountSelection.amount, + queryFn: async (): Promise< + | { + type: "preparedResult"; + result: Extract; + request: Extract; + } + | { + type: "quote"; + result: Buy.quote.Result | Sell.quote.Result; + } + > => { if ( !buyTokenWithPrices || !sellTokenWithPrices || - !props.activeWalletInfo || !props.amountSelection.amount ) { throw new Error("Invalid state"); } + if (!props.activeWalletInfo) { + if (props.amountSelection.type === "buy") { + const res = await Buy.quote({ + amount: toUnits( + props.amountSelection.amount, + buyTokenWithPrices.decimals, + ), + // origin = sell + originChainId: sellTokenWithPrices.chainId, + originTokenAddress: sellTokenWithPrices.address, + // destination = buy + destinationChainId: buyTokenWithPrices.chainId, + destinationTokenAddress: buyTokenWithPrices.address, + client: props.client, + }); + + return { + type: "quote", + result: res, + }; + } + + const res = await Sell.quote({ + amount: toUnits( + props.amountSelection.amount, + sellTokenWithPrices.decimals, + ), + // origin = sell + originChainId: sellTokenWithPrices.chainId, + originTokenAddress: sellTokenWithPrices.address, + // destination = buy + destinationChainId: buyTokenWithPrices.chainId, + destinationTokenAddress: buyTokenWithPrices.address, + client: props.client, + }); + + return { + type: "quote", + result: res, + }; + } + if (props.amountSelection.type === "buy") { const buyRequestOptions: BuyPrepare.Options = { amount: toUnits( @@ -235,6 +283,7 @@ function SwapUIBase( const res = await Buy.prepare(buyRequest); return { + type: "preparedResult", result: { type: "buy", ...res }, request: buyRequest, }; @@ -263,6 +312,7 @@ function SwapUIBase( }; return { + type: "preparedResult", result: { type: "sell", ...res }, request: sellRequest, }; @@ -425,7 +475,8 @@ function SwapUIBase( preparedResultQuery.data && buyTokenWithPrices && sellTokenWithPrices && - sellTokenBalanceQuery.data + sellTokenBalanceQuery.data && + preparedResultQuery.data.type === "preparedResult" ) { props.onSwap({ result: preparedResultQuery.data.result, @@ -570,7 +621,7 @@ function TokenSection(props: { justifyContent: "space-between", gap: spacing.sm, paddingBottom: spacing.sm, - paddingTop: spacing.xs, + paddingTop: spacing.sm, }} > {props.amount.isFetching ? ( @@ -756,9 +807,9 @@ function SelectedTokenButton(props: { {/* token symbol and chain name */} - + {props.selectedToken?.isFetching ? ( - + ) : ( {props.selectedToken?.data?.symbol} From 9d17bdc010529a33fa38c575784f5c5df7a00ebe Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Tue, 16 Sep 2025 18:18:57 +0530 Subject: [PATCH 20/36] combine owned tokens and all tokens --- .../web/ui/Bridge/swap-widget/SearchInput.tsx | 7 +- .../Bridge/swap-widget/SelectChainButton.tsx | 5 +- .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 4 +- .../web/ui/Bridge/swap-widget/common.tsx | 22 + .../Bridge/swap-widget/select-buy-token.tsx | 321 ------------- ...ect-sell-token.tsx => select-token-ui.tsx} | 421 ++++++++---------- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 63 +-- .../web/ui/Bridge/swap-widget/use-tokens.ts | 8 +- .../src/react/web/ui/components/Img.tsx | 27 +- .../Bridge/Swap/SelectBuyToken.stories.tsx | 53 --- .../Bridge/Swap/SelectSellToken.stories.tsx | 82 ---- .../Bridge/Swap/SwapWidget.stories.tsx | 19 + 12 files changed, 289 insertions(+), 743 deletions(-) create mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/common.tsx delete mode 100644 packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-buy-token.tsx rename packages/thirdweb/src/react/web/ui/Bridge/swap-widget/{select-sell-token.tsx => select-token-ui.tsx} (53%) delete mode 100644 packages/thirdweb/src/stories/Bridge/Swap/SelectBuyToken.stories.tsx delete mode 100644 packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SearchInput.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SearchInput.tsx index 90526c527f1..969fdade1b6 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SearchInput.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SearchInput.tsx @@ -1,5 +1,9 @@ import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; -import { iconSize, spacing } from "../../../../core/design-system/index.js"; +import { + iconSize, + radius, + spacing, +} from "../../../../core/design-system/index.js"; import { Container } from "../../components/basic.js"; import { Input } from "../../components/formElements.js"; @@ -22,6 +26,7 @@ export function SearchInput(props: { position: "absolute", left: spacing.sm, top: "50%", + borderRadius: radius.lg, transform: "translateY(-50%)", }} /> diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SelectChainButton.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SelectChainButton.tsx index c7f40646aa4..d94503ee680 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SelectChainButton.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SelectChainButton.tsx @@ -4,6 +4,7 @@ import type { ThirdwebClient } from "../../../../../client/client.js"; import { fontSize, iconSize, + radius, spacing, } from "../../../../core/design-system/index.js"; import { Button } from "../../components/buttons.js"; @@ -17,13 +18,15 @@ export function SelectChainButton(props: { }) { return ( - )} - - {props.isPending && - new Array(20).fill(0).map(() => ( - // biome-ignore lint/correctness/useJsxKeyInIterable: ok - - ))} - - {props.tokens.length === 0 && !props.isPending && ( -
- - No Tokens Found - -
- )} -
-
- - )} -
- ); - } - - if (screen === "select-chain") { - return ( - setScreen("select-token")} - client={props.client} - onSelectChain={(chain) => { - props.setSelectedChain(chain); - setScreen("select-token"); - }} - selectedChain={props.selectedChain} - /> - ); - } - - return null; -} - -function TokenButton(props: { - token: Token; - client: ThirdwebClient; - onSelect: (tokenWithPrices: TokenSelection) => void; - isSelected: boolean; -}) { - return ( - - ); -} - -function TokenButtonSkeleton() { - return ( -
- -
- - -
-
- ); -} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx similarity index 53% rename from packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx rename to packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx index 0745cfecdd8..aef8f202cb1 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-sell-token.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx @@ -1,9 +1,6 @@ -import { - Cross1Icon, - DiscIcon, - MagnifyingGlassIcon, -} from "@radix-ui/react-icons"; -import { useEffect, useState } from "react"; +import { DiscIcon } from "@radix-ui/react-icons"; +import { useMemo, useState } from "react"; +import type { Token } from "../../../../../bridge/index.js"; import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; import { toTokens } from "../../../../../utils/units.js"; @@ -12,177 +9,106 @@ import { iconSize, radius, spacing, - type Theme, } from "../../../../core/design-system/index.js"; -import { ConnectButton } from "../../ConnectWallet/ConnectButton.js"; import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js"; import { Container, Line, ModalHeader } from "../../components/basic.js"; import { Button } from "../../components/buttons.js"; -import { Input } from "../../components/formElements.js"; import { Img } from "../../components/Img.js"; import { Skeleton } from "../../components/Skeleton.js"; import { Spacer } from "../../components/Spacer.js"; import { Spinner } from "../../components/Spinner.js"; import { Text } from "../../components/text.js"; +import { DecimalRenderer } from "./common.js"; +import { SearchInput } from "./SearchInput.js"; import { SelectChainButton } from "./SelectChainButton.js"; import { SelectBridgeChain } from "./select-chain.js"; import type { ActiveWalletInfo, TokenSelection } from "./types.js"; import { useBridgeChains } from "./use-bridge-chains.js"; -import { type TokenBalance, useTokenBalances } from "./use-tokens.js"; +import { + type TokenBalance, + useTokenBalances, + useTokens, +} from "./use-tokens.js"; -type SelectSellTokenProps = { +type SelectTokenUIProps = { onBack: () => void; client: ThirdwebClient; selectedToken: TokenSelection | undefined; setSelectedToken: (token: TokenSelection) => void; + activeWalletInfo: ActiveWalletInfo | undefined; }; function getDefaultSelectedChain( chains: BridgeChain[], - chainId: number | undefined, + activeChainId: number | undefined, ) { - return chains.find((chain) => chain.chainId === (chainId || 1)); -} - -export function SelectSellToken( - props: SelectSellTokenProps & { - activeWalletInfo: ActiveWalletInfo | undefined; - theme: Theme | "light" | "dark"; - }, -) { - if (props.activeWalletInfo) { - return ( - - ); - } - return ( - - ); -} - -export function SelectSellTokenDisconnectedUI(props: { - onBack: () => void; - client: ThirdwebClient; - theme: Theme | "light" | "dark"; -}) { - return ( - - - - - - - - - - - - - Wallet is not connected - - - Connect your wallet to view your tokens - - - - - - - - ); + return chains.find((chain) => chain.chainId === (activeChainId || 1)); } -function SelectSellTokenConnected( - props: SelectSellTokenProps & { - activeWalletInfo: ActiveWalletInfo; - }, -) { +export function SelectToken(props: SelectTokenUIProps) { const chainQuery = useBridgeChains(props.client); const [search, setSearch] = useState(""); - const [limit, setLimit] = useState(50); + const [limit, setLimit] = useState(1000); - const [selectedChain, setSelectedChain] = useState( - () => { - if (!chainQuery.data) { - return undefined; - } - return getDefaultSelectedChain( - chainQuery.data, - props.selectedToken?.chainId || props.activeWalletInfo.activeChain.id, - ); - }, + const [_selectedChain, setSelectedChain] = useState( + undefined, ); - - useEffect(() => { - if (chainQuery.data && !selectedChain) { - setSelectedChain( - getDefaultSelectedChain( + const selectedChain = + _selectedChain || + (chainQuery.data + ? getDefaultSelectedChain( chainQuery.data, - props.selectedToken?.chainId || props.activeWalletInfo.activeChain.id, - ), - ); - } - }, [ - chainQuery.data, - selectedChain, - props.activeWalletInfo.activeChain.id, - props.selectedToken?.chainId, - ]); + props.selectedToken?.chainId || + props.activeWalletInfo?.activeChain.id, + ) + : undefined); - // TODO - useTokenBalances doesn't support all the bridge chains, we need to add a fallback to show all the tokens and not just owned tokens when a chain is not supported - const tokensQuery = useTokenBalances({ + // all tokens + const tokensQuery = useTokens({ + client: props.client, + chainId: selectedChain?.chainId, + search, + limit, + offset: 0, + }); + + // owned tokens + const ownedTokensQuery = useTokenBalances({ clientId: props.client.clientId, chainId: selectedChain?.chainId, limit, page: 1, - walletAddress: props.activeWalletInfo.activeAccount.address, + walletAddress: props.activeWalletInfo?.activeAccount.address, }); + const filteredOwnedTokens = useMemo(() => { + return ownedTokensQuery.data?.tokens?.filter((token) => { + return ( + token.symbol.toLowerCase().includes(search.toLowerCase()) || + token.name.toLowerCase().includes(search.toLowerCase()) || + token.token_address.toLowerCase().includes(search.toLowerCase()) + ); + }); + }, [ownedTokensQuery.data?.tokens, search]); + + const isFetching = tokensQuery.isFetching || ownedTokensQuery.isFetching; + return ( - { - setLimit(tokensQuery.data.pagination.totalCount); + setLimit(limit * 2); } : undefined } @@ -190,10 +116,10 @@ function SelectSellTokenConnected( ); } -export function SelectSellTokenConnectedUI( - props: SelectSellTokenProps & { - activeWalletInfo: ActiveWalletInfo; - tokens: TokenBalance[]; +export function SelectTokenUI( + props: SelectTokenUIProps & { + ownedTokens: TokenBalance[]; + allTokens: Token[]; isPending: boolean; selectedChain: BridgeChain | undefined; setSelectedChain: (chain: BridgeChain) => void; @@ -201,20 +127,29 @@ export function SelectSellTokenConnectedUI( setSearch: (search: string) => void; selectedToken: TokenSelection | undefined; setSelectedToken: (token: TokenSelection) => void; - showAll: (() => void) | undefined; + showMore: (() => void) | undefined; }, ) { const [screen, setScreen] = useState<"select-chain" | "select-token">( "select-token", ); - const filteredTokens = props.tokens.filter((token) => { - return ( - token.symbol.toLowerCase().includes(props.search.toLowerCase()) || - token.name.toLowerCase().includes(props.search.toLowerCase()) || - token.token_address.toLowerCase().includes(props.search.toLowerCase()) + const otherTokens = useMemo(() => { + const ownedTokenSet = new Set( + props.ownedTokens.map((t) => + `${t.token_address}-${t.chain_id}`.toLowerCase(), + ), ); - }); + return props.allTokens.filter( + (token) => + !ownedTokenSet.has(`${token.address}-${token.chainId}`.toLowerCase()), + ); + }, [props.allTokens, props.ownedTokens]); + + const noTokensFound = + !props.isPending && + otherTokens.length === 0 && + props.ownedTokens.length === 0; if (screen === "select-token") { return ( @@ -249,83 +184,71 @@ export function SelectSellTokenConnectedUI( {/* search */} -
- - - - - props.setSearch(e.target.value)} - /> -
+
+ - {filteredTokens.map((token) => { - return ( + {props.isPending && + new Array(20).fill(0).map(() => ( + // biome-ignore lint/correctness/useJsxKeyInIterable: ok + + ))} + + {!props.isPending && + props.ownedTokens.map((token) => ( - ); - })} + ))} - {props.showAll && ( + {!props.isPending && + otherTokens.map((token) => ( + + ))} + + {props.showMore && ( )} - {props.isPending && - new Array(20).fill(0).map(() => ( - // biome-ignore lint/correctness/useJsxKeyInIterable: ok - - ))} - - {filteredTokens.length === 0 && !props.isPending && ( + {noTokensFound && (
+ +
+ + +
+
+ ); +} + function TokenButton(props: { - token: TokenBalance; + token: TokenBalance | Token; client: ThirdwebClient; onSelect: (tokenWithPrices: TokenSelection) => void; isSelected: boolean; }) { - const tokenBalanceInUnits = toTokens( - BigInt(props.token.balance), - props.token.decimals, - ); + const tokenBalanceInUnits = + "balance" in props.token + ? toTokens(BigInt(props.token.balance), props.token.decimals) + : undefined; const usdValue = - props.token.price_data.price_usd * Number(tokenBalanceInUnits); + "balance" in props.token + ? props.token.price_data.price_usd * Number(tokenBalanceInUnits) + : undefined; return ( ); } - -function TokenButtonSkeleton() { - return ( -
- -
- - -
-
- ); -} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index c999d1a2005..05ceb8fc515 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -46,8 +46,8 @@ import { Img } from "../../components/Img.js"; import { Skeleton } from "../../components/Skeleton.js"; import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; -import { SelectBuyToken } from "./select-buy-token.js"; -import { SelectSellToken } from "./select-sell-token.js"; +import { DecimalRenderer } from "./common.js"; +import { SelectToken } from "./select-token-ui.js"; import type { ActiveWalletInfo, SwapWidgetConnectOptions, @@ -103,13 +103,22 @@ export function SwapUI(props: SwapUIProps) { if (screen === "select-buy-token") { return ( - setScreen("base")} client={props.client} selectedToken={props.buyToken} setSelectedToken={(token) => { props.setBuyToken(token); setScreen("base"); + // if buy token is same as sell token, unset sell token + if ( + props.sellToken && + token.tokenAddress === props.sellToken.tokenAddress && + token.chainId === props.sellToken.chainId + ) { + props.setSellToken(undefined); + } }} /> ); @@ -117,14 +126,21 @@ export function SwapUI(props: SwapUIProps) { if (screen === "select-sell-ui") { return ( - setScreen("base")} client={props.client} selectedToken={props.sellToken} - theme={props.theme} setSelectedToken={(token) => { props.setSellToken(token); setScreen("base"); + // if sell token is same as buy token, unset buy token + if ( + props.buyToken && + token.tokenAddress === props.buyToken.tokenAddress && + token.chainId === props.buyToken.chainId + ) { + props.setBuyToken(undefined); + } }} activeWalletInfo={props.activeWalletInfo} /> @@ -609,11 +625,12 @@ function TokenSection(props: { bg="tertiaryBg" style={{ borderRadius: radius.lg, borderWidth: 1, borderStyle: "solid" }} > - {/* label */} + {/* row1 : label */} {props.label} + {/* row2 : amount and select token */}
+ {/* row3 : fiat value/error and balance */}
) : (
- +
)}
)} {/* Balance */} - {props.isConnected && ( + {props.isConnected && props.selectedToken && (
- {props.balance.isFetching || - props.balance.data === undefined || - !props.selectedToken?.data ? ( + {props.balance.data === undefined || + props.selectedToken.data === undefined ? ( ) : ( - - {integerPart}. - - - {fractionPart || "00000"} - -
- ); -} - function SelectedTokenButton(props: { selectedToken: | { diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts index 75bb9aeb542..89ffb140995 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/use-tokens.ts @@ -73,15 +73,15 @@ export function useTokenBalances(options: { clientId: string; page: number; limit: number; - walletAddress: string; + walletAddress: string | undefined; chainId: number | undefined; }) { return useQuery({ queryKey: ["bridge/v1/wallets", options], - enabled: !!options.chainId, + enabled: !!options.chainId && !!options.walletAddress, queryFn: async () => { - if (!options.chainId) { - throw new Error("Chain ID is required"); + if (!options.chainId || !options.walletAddress) { + throw new Error("invalid options"); } const baseUrl = getThirdwebBaseUrl("bridge"); const isDev = baseUrl.includes("thirdweb-dev"); diff --git a/packages/thirdweb/src/react/web/ui/components/Img.tsx b/packages/thirdweb/src/react/web/ui/components/Img.tsx index 8689a6a7b21..b81653c4028 100644 --- a/packages/thirdweb/src/react/web/ui/components/Img.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Img.tsx @@ -26,30 +26,15 @@ export const Img: React.FC<{ "pending", ); - const status = - props.src === undefined - ? "pending" - : props.src === "" - ? "fallback" - : _status; - const propSrc = props.src; const widthPx = `${props.width}px`; const heightPx = `${props.height || props.width}px`; - if (propSrc === undefined) { - return ( - - ); - } - const getSrc = () => { + if (propSrc === undefined) { + return undefined; + } try { return resolveScheme({ client: props.client, @@ -61,6 +46,10 @@ export const Img: React.FC<{ }; const src = getSrc(); + + const status = + src === undefined ? "pending" : src === "" ? "fallback" : _status; + const isLoaded = status === "loaded"; return ( @@ -120,7 +109,7 @@ export const Img: React.FC<{ onLoad={() => { setStatus("loaded"); }} - src={src} + src={src || undefined} style={{ height: !isLoaded ? 0 diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SelectBuyToken.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SelectBuyToken.stories.tsx deleted file mode 100644 index b6875696254..00000000000 --- a/packages/thirdweb/src/stories/Bridge/Swap/SelectBuyToken.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { Meta } from "@storybook/react-vite"; -import { useState } from "react"; -import type { BridgeChain } from "../../../bridge/types/Chain.js"; -import { SwapWidgetContainer } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; -import { - SelectBuyToken, - SelectBuyTokenUI, -} from "../../../react/web/ui/Bridge/swap-widget/select-buy-token.js"; -import { storyClient } from "../../utils.js"; - -const meta = { - parameters: { - layout: "centered", - }, - title: "Bridge/Swap/screens/SelectBuyTokenUI", -} satisfies Meta; -export default meta; - -export function ChainLoading() { - const [selectedChain, setSelectedChain] = useState( - undefined, - ); - return ( - - {}} - selectedChain={selectedChain} - tokens={[]} - isPending={true} - selectedToken={undefined} - setSelectedToken={() => {}} - search={""} - showMore={() => {}} - setSearch={() => {}} - /> - - ); -} - -export function WithData() { - return ( - - {}} - selectedToken={undefined} - setSelectedToken={() => {}} - /> - - ); -} diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx deleted file mode 100644 index 9c1b82b96b7..00000000000 --- a/packages/thirdweb/src/stories/Bridge/Swap/SelectSellToken.stories.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { Meta } from "@storybook/react-vite"; -import { useState } from "react"; -import type { BridgeChain } from "../../../bridge/types/Chain.js"; -import { useActiveAccount } from "../../../react/core/hooks/wallets/useActiveAccount.js"; -import { useActiveWallet } from "../../../react/core/hooks/wallets/useActiveWallet.js"; -import { useActiveWalletChain } from "../../../react/core/hooks/wallets/useActiveWalletChain.js"; -import { SwapWidgetContainer } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; -import { - type SelectSellToken, - SelectSellTokenConnectedUI, - SelectSellTokenDisconnectedUI, -} from "../../../react/web/ui/Bridge/swap-widget/select-sell-token.js"; -import type { ActiveWalletInfo } from "../../../react/web/ui/Bridge/swap-widget/types.js"; -import { ConnectButton } from "../../../react/web/ui/ConnectWallet/ConnectButton.js"; -import { storyClient } from "../../utils.js"; - -const meta = { - parameters: { - layout: "centered", - }, - title: "Bridge/Swap/screens/SelectSellTokenUI", -} satisfies Meta; -export default meta; - -export function ChainLoading() { - const [selectedChain, setSelectedChain] = useState( - undefined, - ); - - const activeChain = useActiveWalletChain(); - const activeWallet = useActiveWallet(); - const activeAccount = useActiveAccount(); - - const activeWalletInfo: ActiveWalletInfo | undefined = - activeAccount && activeWallet && activeChain - ? { - activeChain, - activeWallet, - activeAccount, - } - : undefined; - - if (!activeWalletInfo) { - return ( -
-

connect wallet to view story

- -
- ); - } - - return ( - - {}} - selectedChain={selectedChain} - tokens={[]} - isPending={true} - selectedToken={undefined} - setSelectedToken={() => {}} - search={""} - showAll={() => {}} - setSearch={() => {}} - activeWalletInfo={activeWalletInfo} - /> - - ); -} - -export function Disconnected() { - return ( - - {}} - theme="dark" - /> - - ); -} diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx index 2aa71cb9ad9..3e7db98d94c 100644 --- a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx @@ -1,6 +1,7 @@ import type { Meta } from "@storybook/react-vite"; import { lightTheme } from "../../../react/core/design-system/index.js"; import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; +import { ConnectButton } from "../../../react/web/ui/ConnectWallet/ConnectButton.js"; import { storyClient } from "../../utils.js"; const meta = { @@ -8,6 +9,24 @@ const meta = { layout: "centered", }, title: "Bridge/Swap/SwapWidget", + decorators: [ + (Story) => { + return ( +
+ +
+ +
+
+ ); + }, + ], } satisfies Meta; export default meta; From ed5ce8a26e7da9949b102d5782a7f06d2920da0e Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Tue, 16 Sep 2025 19:33:01 +0530 Subject: [PATCH 21/36] update --- .../public-pages/erc20/_components/PayEmbedSection.tsx | 1 + packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx | 4 +++- .../web/ui/Bridge/payment-details/PaymentDetails.tsx | 9 +++++++-- .../src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx | 3 +++ .../src/stories/Bridge/Swap/SwapWidget.stories.tsx | 6 +++--- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx index 71eb7f0b8ea..6e08094123b 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx @@ -64,6 +64,7 @@ export function BuyTokenEmbed(props: { }} theme={themeObj} tokenAddress={props.tokenAddress as `0x${string}`} + paymentMethods={["card"]} /> )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx index 80b8fc24064..7679dc4f1cb 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx @@ -25,6 +25,7 @@ import { Spinner } from "../components/Spinner.js"; import { Text } from "../components/text.js"; interface StepRunnerProps { + title?: string; request: BridgePrepareRequest; /** @@ -64,6 +65,7 @@ interface StepRunnerProps { } export function StepRunner({ + title, request, wallet, client, @@ -248,7 +250,7 @@ export function StepRunner({ return ( - + diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx index f2ccf7506e0..fa4c1a893b2 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx @@ -21,6 +21,9 @@ import type { UIOptions } from "../BridgeOrchestrator.js"; import { PaymentOverview } from "./PaymentOverview.js"; export interface PaymentDetailsProps { + title?: string; + confirmButtonLabel?: string; + /** * The UI mode to use */ @@ -55,6 +58,8 @@ export interface PaymentDetailsProps { } export function PaymentDetails({ + title, + confirmButtonLabel, uiOptions, client, paymentMethod, @@ -262,7 +267,7 @@ export function PaymentDetails({ return ( - + @@ -427,7 +432,7 @@ export function PaymentDetails({ {/* Action Buttons */} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index c233a49745e..49a4607c9f7 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -352,6 +352,8 @@ function SwapWidgetContent(props: SwapWidgetProps) { if (screen.id === "2:preview") { return ( { setScreen({ id: "1:swap-ui" }); @@ -384,6 +386,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { if (screen.id === "3:execute") { return ( { diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx index 3e7db98d94c..03bafc8bb8a 100644 --- a/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/Swap/SwapWidget.stories.tsx @@ -1,10 +1,10 @@ -import type { Meta } from "@storybook/react-vite"; +import type { Meta } from "@storybook/react"; import { lightTheme } from "../../../react/core/design-system/index.js"; import { SwapWidget } from "../../../react/web/ui/Bridge/swap-widget/SwapWidget.js"; import { ConnectButton } from "../../../react/web/ui/ConnectWallet/ConnectButton.js"; import { storyClient } from "../../utils.js"; -const meta = { +const meta: Meta = { parameters: { layout: "centered", }, @@ -27,7 +27,7 @@ const meta = { ); }, ], -} satisfies Meta; +}; export default meta; export function BasicUsage() { From b2d64290f3927dfe386157092f47f320f03e0af3 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Tue, 16 Sep 2025 20:04:48 +0530 Subject: [PATCH 22/36] Fix buy widget showing payment method selection screen --- .../src/react/web/ui/Bridge/DirectPayment.tsx | 2 +- .../web/ui/Bridge/TransactionPayment.tsx | 2 +- .../react/web/ui/Bridge/common/WithHeader.tsx | 2 +- .../payment-selection/PaymentSelection.tsx | 16 +++++++++++--- .../payment-selection/WalletFiatSelection.tsx | 19 ++++++++++------ .../src/stories/BuyWidget.stories.tsx | 22 +++++++++++++++++++ 6 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx index 4fd17780466..3cfd3ff82fa 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/DirectPayment.tsx @@ -100,7 +100,7 @@ export function DirectPayment({ size="xl" token={uiOptions.paymentInfo.token} tokenAmount={uiOptions.paymentInfo.amount} - weight={700} + weight={600} /> {/* USD Value */} - + {transactionDataQuery.data?.usdValueDisplay || transactionDataQuery.data?.txCostDisplay} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx index 053f033f471..0b67ab0f60e 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx @@ -46,7 +46,7 @@ export function WithHeader({ <> {/* title */} {showTitle && ( - + {uiOptions.metadata?.title || defaultTitle} )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx index 13745190522..7b6f63204df 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx @@ -125,9 +125,16 @@ export function PaymentSelection({ const connectedWallets = useConnectedWallets(); const activeWallet = useActiveWallet(); - const [currentStep, setCurrentStep] = useState({ - type: "walletSelection", - }); + const initialStep = + paymentMethods.length === 1 && paymentMethods[0] === "card" + ? { + type: "fiatProviderSelection" as const, + } + : { + type: "walletSelection" as const, + }; + + const [currentStep, setCurrentStep] = useState(initialStep); useQuery({ queryFn: () => { @@ -226,6 +233,9 @@ export function PaymentSelection({ }; const getBackHandler = () => { + if (paymentMethods.length === 1 && paymentMethods[0] === "card") { + return onBack; + } switch (currentStep.type) { case "walletSelection": return onBack; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/WalletFiatSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/WalletFiatSelection.tsx index 8afa48c1359..3768bf2818d 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/WalletFiatSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/WalletFiatSelection.tsx @@ -38,10 +38,15 @@ export function WalletFiatSelection({ <> {paymentMethods.includes("crypto") && ( <> - - Pay with Crypto - - + {paymentMethods.length > 1 && ( + <> + + Pay with Crypto + + + + )} + {/* Connected Wallets */} {connectedWallets.length > 0 && ( <> @@ -134,13 +139,13 @@ export function WalletFiatSelection({ {paymentMethods.includes("card") && ( <> - + - + Pay with Card - + - + + ); }) ) : ( - - - - - Generating quotes... + + + + + Searching Providers + + + + Searching for the best providers for this payment )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx index 7b6f63204df..a42d0b9ec97 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx @@ -282,10 +282,9 @@ export function PaymentSelection({ return ( + - - - + {currentStep.type === "walletSelection" && ( Date: Wed, 17 Sep 2025 01:13:08 +0530 Subject: [PATCH 27/36] ui updates in buy widget --- .../thirdweb/src/react/web/ui/Bridge/FundWallet.tsx | 13 +++++++------ .../react/web/ui/Bridge/common/TokenAndChain.tsx | 5 +++-- .../ui/Bridge/payment-details/PaymentDetails.tsx | 2 +- .../payment-selection/FiatProviderSelection.tsx | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx index 10c01a8bcb1..c5c80544152 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx @@ -150,7 +150,7 @@ export function FundWallet({ style={{ backgroundColor: theme.colors.tertiaryBg, border: `1px solid ${theme.colors.borderColor}`, - borderRadius: radius.md, + borderRadius: radius.lg, flexWrap: "nowrap", }} > @@ -251,11 +251,11 @@ export function FundWallet({ {/* Quick Amount Buttons */} {presetOptions && ( <> - + )} - + {receiver ? ( @@ -336,7 +337,7 @@ export function FundWallet({ }} style={{ fontSize: fontSize.md, - padding: `${spacing.sm} ${spacing.md}`, + borderRadius: radius.lg, }} variant="primary" > diff --git a/packages/thirdweb/src/react/web/ui/Bridge/common/TokenAndChain.tsx b/packages/thirdweb/src/react/web/ui/Bridge/common/TokenAndChain.tsx index a8b7b1e2de1..c36dad4e7ec 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/common/TokenAndChain.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/common/TokenAndChain.tsx @@ -60,7 +60,7 @@ export function TokenAndChain({ height: size === "lg" || size === "xl" ? iconSize.sm : iconSize.xs, position: "absolute", - right: "-6px", + right: "-2px", width: size === "lg" || size === "xl" ? iconSize.sm : iconSize.xs, }} > @@ -73,7 +73,7 @@ export function TokenAndChain({ )} - + {token.name} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx index fa4c1a893b2..e4b2c5d01dd 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx @@ -269,7 +269,7 @@ export function PaymentDetails({ - + {/* Quote Summary */} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx index 51dd9a2c136..ae434bf050d 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx @@ -189,7 +189,7 @@ export function FiatProviderSelection({ > - + Searching Providers From 9cf8688a9efab10d6af3b2cc7644abc4d72588d7 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Wed, 17 Sep 2025 01:38:21 +0530 Subject: [PATCH 28/36] better switch ux, swap label --- .../src/react/web/ui/Bridge/swap-widget/swap-ui.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 433f141ca5f..0d20abef30b 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -307,6 +307,10 @@ function SwapUIBase( const temp = props.sellToken; props.setSellToken(props.buyToken); props.setBuyToken(temp); + props.setAmountSelection({ + type: props.amountSelection.type === "buy" ? "sell" : "buy", + amount: props.amountSelection.amount, + }); }} /> @@ -360,7 +364,7 @@ function SwapUIBase( Date: Wed, 17 Sep 2025 02:22:56 +0530 Subject: [PATCH 29/36] move token selection to modal --- .../erc20/_components/PayEmbedSection.tsx | 2 +- .../src/react/core/design-system/index.ts | 1 + .../src/react/web/ui/Bridge/StepRunner.tsx | 2 +- .../react/web/ui/Bridge/common/WithHeader.tsx | 2 +- .../Bridge/payment-details/PaymentDetails.tsx | 2 +- .../payment-selection/PaymentSelection.tsx | 2 +- .../Bridge/payment-success/SuccessScreen.tsx | 43 +++-- .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 1 + .../ui/Bridge/swap-widget/select-chain.tsx | 2 +- .../ui/Bridge/swap-widget/select-token-ui.tsx | 17 +- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 152 ++++++++---------- .../web/ui/ConnectWallet/ConnectButton.tsx | 1 + .../react/web/ui/ConnectWallet/Details.tsx | 1 + .../ui/ConnectWallet/Modal/ConnectModal.tsx | 1 + .../web/ui/ConnectWallet/NetworkSelector.tsx | 1 + .../ui/TransactionButton/TransactionModal.tsx | 1 + .../src/react/web/ui/components/Modal.tsx | 26 +-- .../src/react/web/ui/components/basic.tsx | 10 ++ 18 files changed, 141 insertions(+), 126 deletions(-) diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx index 6e08094123b..dac716b898b 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx @@ -23,7 +23,7 @@ export function BuyTokenEmbed(props: { const themeObj = getSDKTheme(theme === "light" ? "light" : "dark"); return (
-
+
setTab("swap")} diff --git a/packages/thirdweb/src/react/core/design-system/index.ts b/packages/thirdweb/src/react/core/design-system/index.ts index 8854e7ce51e..023270dde16 100644 --- a/packages/thirdweb/src/react/core/design-system/index.ts +++ b/packages/thirdweb/src/react/core/design-system/index.ts @@ -177,6 +177,7 @@ export const spacing = { "4xs": "2px", lg: "24px", md: "16px", + "md+": "20px", sm: "12px", xl: "32px", xs: "8px", diff --git a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx index 7679dc4f1cb..0bc9057ab30 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx @@ -249,7 +249,7 @@ export function StepRunner({ }; return ( - + diff --git a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx index 0b67ab0f60e..e93a4c871cd 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/common/WithHeader.tsx @@ -40,7 +40,7 @@ export function WithHeader({ )} - + {(showTitle || uiOptions.metadata?.description) && ( <> diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx index e4b2c5d01dd..cd48413414f 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx @@ -266,7 +266,7 @@ export function PaymentDetails({ const displayData = getDisplayData(); return ( - + diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx index a42d0b9ec97..db86c04755e 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/PaymentSelection.tsx @@ -280,7 +280,7 @@ export function PaymentSelection({ } return ( - + diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx index 2c13c1162be..8d491066020 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-success/SuccessScreen.tsx @@ -6,7 +6,7 @@ import { trackPayEvent } from "../../../../../analytics/track/pay.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; import type { WindowAdapter } from "../../../../core/adapters/WindowAdapter.js"; import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; -import { iconSize } from "../../../../core/design-system/index.js"; +import { iconSize, spacing } from "../../../../core/design-system/index.js"; import type { BridgePrepareResult } from "../../../../core/hooks/useBridgePrepare.js"; import type { CompletedStatusResult } from "../../../../core/hooks/useStepExecutor.js"; import { Container, ModalHeader } from "../../components/basic.js"; @@ -91,10 +91,10 @@ export function SuccessScreen({ } return ( - + - + {/* Success Icon with Animation */} @@ -103,7 +103,6 @@ export function SuccessScreen({ flex="row" style={{ animation: "successBounce 0.6s ease-out", - backgroundColor: theme.colors.tertiaryBg, border: `2px solid ${theme.colors.success}`, borderRadius: "50%", height: "64px", @@ -121,19 +120,31 @@ export function SuccessScreen({ /> - - Payment Successful! - - - - {hasPaymentId - ? "You can now close this page and return to the application." - : uiOptions.mode === "transaction" - ? "Click continue to execute your transaction." - : "Your payment has been completed successfully."} - +
+ + Payment Successful! + + + + {hasPaymentId + ? "You can now close this page and return to the application." + : uiOptions.mode === "transaction" + ? "Click continue to execute your transaction." + : "Your payment has been completed successfully."} + +
- + + {/* Action Buttons */} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 75db6301631..4b935c649e4 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -346,6 +346,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { } return undefined; }); + const [sellToken, setSellToken] = useState(() => { if (props.prefill?.sellToken) { return { diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx index 7148f4ed09d..64105629a9d 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx @@ -51,7 +51,7 @@ export function SelectBridgeChainUI( return (
- + diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx index 7ae3e347220..df2ccaa4567 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx @@ -182,7 +182,7 @@ function SelectTokenUI( if (screen === "select-token") { return ( - + @@ -193,7 +193,7 @@ function SelectTokenUI( display: "flex", alignItems: "center", justifyContent: "center", - minHeight: "400px", + height: "400px", }} > @@ -231,9 +231,9 @@ function SelectTokenUI( }} > {props.isFetching && - new Array(20).fill(0).map(() => ( - // biome-ignore lint/correctness/useJsxKeyInIterable: ok - + new Array(20).fill(0).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ok + ))} {!props.isFetching && sortedOwnedTokens.length > 0 && ( @@ -267,8 +267,8 @@ function SelectTokenUI( client={props.client} onSelect={props.setSelectedToken} isSelected={ - props.selectedToken?.tokenAddress === - token.token_address + props.selectedToken?.tokenAddress.toLowerCase() === + token.token_address.toLowerCase() } /> ))} @@ -307,7 +307,8 @@ function SelectTokenUI( client={props.client} onSelect={props.setSelectedToken} isSelected={ - props.selectedToken?.tokenAddress === token.address + props.selectedToken?.tokenAddress.toLowerCase() === + token.address.toLowerCase() } /> ))} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 0d20abef30b..7321dc18645 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -43,6 +43,7 @@ import { Container } from "../../components/basic.js"; import { Button } from "../../components/buttons.js"; import { Input } from "../../components/formElements.js"; import { Img } from "../../components/Img.js"; +import { Modal } from "../../components/Modal.js"; import { Skeleton } from "../../components/Skeleton.js"; import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; @@ -85,71 +86,6 @@ type SwapUIProps = { }) => void; }; -export function SwapUI(props: SwapUIProps) { - const [screen, setScreen] = useState< - "base" | "select-buy-token" | "select-sell-ui" - >("base"); - - if (screen === "base") { - return ( - { - setScreen(type === "buy" ? "select-buy-token" : "select-sell-ui"); - }} - /> - ); - } - - if (screen === "select-buy-token") { - return ( - setScreen("base")} - client={props.client} - selectedToken={props.buyToken} - setSelectedToken={(token) => { - props.setBuyToken(token); - setScreen("base"); - // if buy token is same as sell token, unset sell token - if ( - props.sellToken && - token.tokenAddress === props.sellToken.tokenAddress && - token.chainId === props.sellToken.chainId - ) { - props.setSellToken(undefined); - } - }} - /> - ); - } - - if (screen === "select-sell-ui") { - return ( - setScreen("base")} - client={props.client} - selectedToken={props.sellToken} - setSelectedToken={(token) => { - props.setSellToken(token); - setScreen("base"); - // if sell token is same as buy token, unset buy token - if ( - props.buyToken && - token.tokenAddress === props.buyToken.tokenAddress && - token.chainId === props.buyToken.chainId - ) { - props.setBuyToken(undefined); - } - }} - activeWalletInfo={props.activeWalletInfo} - /> - ); - } - - return null; -} - function useTokenPrice(options: { token: TokenSelection | undefined; client: ThirdwebClient; @@ -172,23 +108,11 @@ function useTokenPrice(options: { }); } -function SwapUIBase( - props: SwapUIProps & { - onSelectToken: (type: "buy" | "sell") => void; - buyToken: TokenSelection | undefined; - sellToken: TokenSelection | undefined; - setBuyToken: (token: TokenSelection | undefined) => void; - setSellToken: (token: TokenSelection | undefined) => void; - amountSelection: { - type: "buy" | "sell"; - amount: string; - }; - setAmountSelection: (amountSelection: { - type: "buy" | "sell"; - amount: string; - }) => void; - }, -) { +export function SwapUI(props: SwapUIProps) { + const [modalState, setModalState] = useState< + "select-buy-token" | "select-sell-token" | undefined + >(undefined); + // Token Prices ---------------------------------------------------------------------------- const buyTokenQuery = useTokenPrice({ token: props.buyToken, @@ -271,6 +195,66 @@ function SwapUIBase( return ( + { + if (!v) { + setModalState(undefined); + } + }} + > + {modalState === "select-buy-token" && ( + setModalState(undefined)} + client={props.client} + selectedToken={props.buyToken} + setSelectedToken={(token) => { + props.setBuyToken(token); + setModalState(undefined); + // if buy token is same as sell token, unset sell token + if ( + props.sellToken && + token.tokenAddress.toLowerCase() === + props.sellToken.tokenAddress.toLowerCase() && + token.chainId === props.sellToken.chainId + ) { + props.setSellToken(undefined); + } + }} + /> + )} + + {modalState === "select-sell-token" && ( + setModalState(undefined)} + client={props.client} + selectedToken={props.sellToken} + setSelectedToken={(token) => { + props.setSellToken(token); + setModalState(undefined); + // if sell token is same as buy token, unset buy token + if ( + props.buyToken && + token.tokenAddress.toLowerCase() === + props.buyToken.tokenAddress.toLowerCase() && + token.chainId === props.buyToken.chainId + ) { + props.setBuyToken(undefined); + } + }} + activeWalletInfo={props.activeWalletInfo} + /> + )} + + {/* Sell */} props.onSelectToken("sell")} + onSelectToken={() => setModalState("select-sell-token")} /> {/* Switch */} @@ -340,7 +324,7 @@ function SwapUIBase( }} client={props.client} currency={props.currency} - onSelectToken={() => props.onSelectToken("buy")} + onSelectToken={() => setModalState("select-buy-token")} /> {/* error message */} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx index f10190f4563..87788ad3dcf 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx @@ -543,6 +543,7 @@ function ConnectButtonInner( )} { if (!_open) { diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModal.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModal.tsx index 953b3a62634..cd9a5568f45 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModal.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModal.tsx @@ -118,6 +118,7 @@ const ConnectModal = (props: ConnectModalOptions) => { return ( { diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/NetworkSelector.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/NetworkSelector.tsx index d401b77d7ee..a0554a2fce7 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/NetworkSelector.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/NetworkSelector.tsx @@ -954,6 +954,7 @@ export function useNetworkSwitcherModal() { setRootEl( { if (!value) { diff --git a/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx b/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx index a65c0331879..380b02094f3 100644 --- a/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx +++ b/packages/thirdweb/src/react/web/ui/TransactionButton/TransactionModal.tsx @@ -68,6 +68,7 @@ export function TransactionModal(props: ModalProps) { return ( { if (!_open) { diff --git a/packages/thirdweb/src/react/web/ui/components/Modal.tsx b/packages/thirdweb/src/react/web/ui/components/Modal.tsx index 6b249dd2fa0..0245dc70084 100644 --- a/packages/thirdweb/src/react/web/ui/components/Modal.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Modal.tsx @@ -36,7 +36,9 @@ export const Modal: React.FC<{ style?: React.CSSProperties; hideCloseIcon?: boolean; size: "wide" | "compact"; + title: string; hide?: boolean; + crossContainerStyles?: React.CSSProperties; }> = (props) => { const [open, setOpen] = useState(props.open); const contentRef = useRef(null); @@ -116,7 +118,7 @@ export const Modal: React.FC<{ width: "1px", }} > - Connect Modal + {props.title} {props.size === "compact" ? ( @@ -128,7 +130,15 @@ export const Modal: React.FC<{ {/* Close Icon */} {!props.hideCloseIcon && ( - +
- +
)} @@ -150,13 +160,6 @@ export const Modal: React.FC<{ ); }; -const CrossContainer = /* @__PURE__ */ StyledDiv({ - position: "absolute", - right: spacing.lg, - top: spacing.lg, - transform: "translateX(6px)", -}); - const modalAnimationDesktop = keyframes` from { opacity: 0; @@ -190,7 +193,7 @@ const DialogContent = /* @__PURE__ */ StyledDiv((_) => { animation: `${modalAnimationDesktop} 300ms ease`, background: theme.colors.modalBg, border: `1px solid ${theme.colors.borderColor}`, - borderRadius: radius.lg, + borderRadius: radius.xl, boxShadow: shadow.lg, boxSizing: "border-box", color: theme.colors.primaryText, @@ -208,7 +211,6 @@ const DialogContent = /* @__PURE__ */ StyledDiv((_) => { animation: `${modalAnimationMobile} 0.35s cubic-bezier(0.15, 1.15, 0.6, 1)`, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, - borderRadius: radius.xl, bottom: 0, left: 0, maxWidth: "none !important", diff --git a/packages/thirdweb/src/react/web/ui/components/basic.tsx b/packages/thirdweb/src/react/web/ui/components/basic.tsx index 0663ffc6bf9..b3b6ea71ec1 100644 --- a/packages/thirdweb/src/react/web/ui/components/basic.tsx +++ b/packages/thirdweb/src/react/web/ui/components/basic.tsx @@ -86,6 +86,8 @@ export function Container(props: { p?: keyof typeof spacing; px?: keyof typeof spacing; py?: keyof typeof spacing; + pb?: keyof typeof spacing; + pt?: keyof typeof spacing; relative?: boolean; scrollY?: boolean; color?: keyof Theme["colors"]; @@ -155,6 +157,14 @@ export function Container(props: { styles.paddingBottom = spacing[props.py]; } + if (props.pb) { + styles.paddingBottom = spacing[props.pb]; + } + + if (props.pt) { + styles.paddingTop = spacing[props.pt]; + } + if (props.debug) { styles.outline = "1px solid red"; styles.outlineOffset = "-1px"; From 33fa31ad964d5a38c09d92451335030519e143e8 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Wed, 17 Sep 2025 16:47:05 +0530 Subject: [PATCH 30/36] address comments --- packages/thirdweb/src/pay/convert/type.ts | 45 ++++--------------- .../FiatProviderSelection.tsx | 2 +- .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 28 ++++++------ .../ui/Bridge/swap-widget/select-chain.tsx | 6 +++ .../ui/Bridge/swap-widget/select-token-ui.tsx | 12 ++++- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 3 ++ .../react/web/ui/Bridge/swap-widget/types.ts | 11 +++-- .../web/ui/Bridge/swap-widget/use-tokens.ts | 2 +- .../ui/ConnectWallet/icons/WalletDotIcon.tsx | 3 ++ .../src/react/web/ui/components/Img.tsx | 23 +++++++++- .../useWalletDetailsModal.stories.tsx | 2 +- 11 files changed, 78 insertions(+), 59 deletions(-) diff --git a/packages/thirdweb/src/pay/convert/type.ts b/packages/thirdweb/src/pay/convert/type.ts index 7040deaa35c..7db0394ed72 100644 --- a/packages/thirdweb/src/pay/convert/type.ts +++ b/packages/thirdweb/src/pay/convert/type.ts @@ -1,39 +1,4 @@ -const CURRENCIES = [ - "USD", - "EUR", - "GBP", - "JPY", - "KRW", - "CNY", - "INR", - "NOK", - "SEK", - "CHF", - "AUD", - "CAD", - "NZD", - "MXN", - "BRL", - "CLP", - "CZK", - "DKK", - "HKD", - "HUF", - "IDR", - "ILS", - "ISK", -] as const; - -export type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {}); - -export function getFiatSymbol(showBalanceInFiat: SupportedFiatCurrency) { - if (currencySymbol[showBalanceInFiat]) { - return currencySymbol[showBalanceInFiat]; - } - return "$"; -} - -const currencySymbol: Record = { +const currencySymbol = { USD: "$", EUR: "€", GBP: "£", @@ -57,4 +22,10 @@ const currencySymbol: Record = { IDR: "Rp", ILS: "₪", ISK: "kr", -}; +} as const; + +export type SupportedFiatCurrency = keyof typeof currencySymbol; + +export function getFiatSymbol(showBalanceInFiat: SupportedFiatCurrency) { + return currencySymbol[showBalanceInFiat] || "$"; +} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx index ae434bf050d..84e763f986e 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/FiatProviderSelection.tsx @@ -159,7 +159,7 @@ export function FiatProviderSelection({ style={{ fontWeight: 500 }} > {formatCurrencyAmount( - currency || "US", + currency || "USD", quote.currencyAmount, )} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 4b935c649e4..9ebf37c214f 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -155,18 +155,6 @@ export type SwapWidgetProps = { }; }; -export function SwapWidget(props: SwapWidgetProps) { - return ( - - - - ); -} - /** * A widget for swapping tokens with cross-chain support * @@ -237,7 +225,21 @@ export function SwapWidget(props: SwapWidgetProps) { * }} /> * ``` * - * @returns + */ +export function SwapWidget(props: SwapWidgetProps) { + return ( + + + + ); +} + +/** + * @internal */ export function SwapWidgetContainer(props: { theme: SwapWidgetProps["theme"]; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx index 64105629a9d..270b5e8e927 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-chain.tsx @@ -23,6 +23,9 @@ type SelectBuyTokenProps = { selectedChain: BridgeChain | undefined; }; +/** + * @internal + */ export function SelectBridgeChain(props: SelectBuyTokenProps) { const chainQuery = useBridgeChains(props.client); @@ -36,6 +39,9 @@ export function SelectBridgeChain(props: SelectBuyTokenProps) { ); } +/** + * @internal + */ export function SelectBridgeChainUI( props: SelectBuyTokenProps & { isPending: boolean; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx index df2ccaa4567..a96362495c8 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/select-token-ui.tsx @@ -32,6 +32,9 @@ import { useTokens, } from "./use-tokens.js"; +/** + * @internal + */ type SelectTokenUIProps = { onBack: () => void; client: ThirdwebClient; @@ -47,6 +50,9 @@ function getDefaultSelectedChain( return chains.find((chain) => chain.chainId === (activeChainId || 1)); } +/** + * @internal + */ export function SelectToken(props: SelectTokenUIProps) { const chainQuery = useBridgeChains(props.client); const [search, setSearch] = useState(""); @@ -267,8 +273,10 @@ function SelectTokenUI( client={props.client} onSelect={props.setSelectedToken} isSelected={ - props.selectedToken?.tokenAddress.toLowerCase() === - token.token_address.toLowerCase() + !!props.selectedToken && + props.selectedToken.tokenAddress.toLowerCase() === + token.token_address.toLowerCase() && + token.chain_id === props.selectedToken.chainId } /> ))} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 7321dc18645..929b763397d 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -108,6 +108,9 @@ function useTokenPrice(options: { }); } +/** + * @internal + */ export function SwapUI(props: SwapUIProps) { const [modalState, setModalState] = useState< "select-buy-token" | "select-sell-token" | undefined diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts index f0c49032d51..63a7ab5b9e4 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts @@ -9,11 +9,11 @@ import type { SiweAuthOptions } from "../../../../core/hooks/auth/useSiweAuth.js import type { ConnectButton_connectModalOptions } from "../../../../core/hooks/connection/ConnectButtonProps.js"; /** - * Connection options for the `BuyWidget` component + * Connection options for the `SwapWidget` component * * @example * ```tsx - * { return ( ( "pending", ); + const imgRef = useRef(null); const propSrc = props.src; @@ -52,6 +53,26 @@ export const Img: React.FC<{ const isLoaded = status === "loaded"; + useEffect(() => { + const imgEl = imgRef.current; + if (!imgEl) { + return; + } + if (imgEl.complete) { + setStatus("loaded"); + } else { + function handleLoad() { + setStatus("loaded"); + } + imgEl.addEventListener("load", handleLoad); + return () => { + imgEl.removeEventListener("load", handleLoad); + }; + } + + return; + }, []); + return (
; + return ; } export function AssetTabs() { From 0d2d39de251a3c89f5a7bc7f3411d8ae07b7015b Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Wed, 17 Sep 2025 17:54:35 +0530 Subject: [PATCH 31/36] pass quote to callbacks, add events --- apps/dashboard/src/@/analytics/report.ts | 47 +++++++ .../erc20/_components/PayEmbedSection.tsx | 28 ++++ .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 127 +++++++++--------- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 12 +- .../react/web/ui/Bridge/swap-widget/types.ts | 6 + 5 files changed, 151 insertions(+), 69 deletions(-) diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts index b847063b594..3c6a467b36b 100644 --- a/apps/dashboard/src/@/analytics/report.ts +++ b/apps/dashboard/src/@/analytics/report.ts @@ -250,6 +250,53 @@ export function reportAssetBuySuccessful(properties: { }); } +type TokenSwapParams = { + buyTokenChainId: number; + buyTokenAddress: string; + sellTokenChainId: number; + sellTokenAddress: string; +}; + +/** + * ### Why do we need to report this event? + * - To track number of successful token swaps from the token page + * - To track which tokens are being swapped the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenSwapSuccessful(properties: TokenSwapParams) { + posthog.capture("token swap successful", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of failed token swaps from the token page + * - To track which tokens are being swapped the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenSwapFailed( + properties: TokenSwapParams & { + errorMessage: string; + }, +) { + posthog.capture("token swap failed", properties); +} + +/** + * ### Why do we need to report this event? + * - To track number of cancelled token swaps from the token page + * - To track which tokens are being swapped the most + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportTokenSwapCancelled(properties: TokenSwapParams) { + posthog.capture("token swap cancelled", properties); +} + /** * ### Why do we need to report this event? * - To track number of failed asset purchases from the token page diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx index dac716b898b..ae5b054b816 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx @@ -7,6 +7,9 @@ import { BuyWidget, SwapWidget } from "thirdweb/react"; import { reportAssetBuyFailed, reportAssetBuySuccessful, + reportTokenSwapCancelled, + reportTokenSwapFailed, + reportTokenSwapSuccessful, } from "@/analytics/report"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -82,6 +85,31 @@ export function BuyTokenEmbed(props: { chainId: props.chain.id, }, }} + onError={(error, quote) => { + reportTokenSwapFailed({ + errorMessage: error.message, + buyTokenChainId: quote.intent.destinationChainId, + buyTokenAddress: quote.intent.destinationTokenAddress, + sellTokenChainId: quote.intent.originChainId, + sellTokenAddress: quote.intent.originTokenAddress, + }); + }} + onSuccess={(quote) => { + reportTokenSwapSuccessful({ + buyTokenChainId: quote.intent.destinationChainId, + buyTokenAddress: quote.intent.destinationTokenAddress, + sellTokenChainId: quote.intent.originChainId, + sellTokenAddress: quote.intent.originTokenAddress, + }); + }} + onCancel={(quote) => { + reportTokenSwapCancelled({ + buyTokenChainId: quote.intent.destinationChainId, + buyTokenAddress: quote.intent.destinationTokenAddress, + sellTokenChainId: quote.intent.originChainId, + sellTokenAddress: quote.intent.originTokenAddress, + }); + }} /> )}
diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 9ebf37c214f..7b3a7eb47ea 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -9,10 +9,7 @@ import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js"; import { getAddress } from "../../../../../utils/address.js"; import { CustomThemeProvider } from "../../../../core/design-system/CustomThemeProvider.js"; import type { Theme } from "../../../../core/design-system/index.js"; -import type { - BridgePrepareRequest, - BridgePrepareResult, -} from "../../../../core/hooks/useBridgePrepare.js"; +import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js"; import type { CompletedStatusResult } from "../../../../core/hooks/useStepExecutor.js"; import { webWindowAdapter } from "../../../adapters/WindowAdapter.js"; import { EmbedContainer } from "../../ConnectWallet/Modal/ConnectEmbed.js"; @@ -24,7 +21,11 @@ import { StepRunner } from "../StepRunner.js"; import { useActiveWalletInfo } from "./hooks.js"; import { getLastUsedTokens, setLastUsedTokens } from "./storage.js"; import { SwapUI } from "./swap-ui.js"; -import type { SwapWidgetConnectOptions, TokenSelection } from "./types.js"; +import type { + SwapPreparedQuote, + SwapWidgetConnectOptions, + TokenSelection, +} from "./types.js"; import { useBridgeChains } from "./use-bridge-chains.js"; export type SwapWidgetProps = { @@ -44,54 +45,6 @@ export type SwapWidgetProps = { * ``` */ client: ThirdwebClient; - /** - * Set the theme for the `SwapWidget` component. By default it is set to `"dark"` - * - * theme can be set to either `"dark"`, `"light"` or a custom theme object. - * You can also import [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme) - * or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme) - * functions from `thirdweb/react` to use the default themes as base and overrides parts of it. - * @example - * ```ts - * import { lightTheme } from "thirdweb/react"; - * - * const customTheme = lightTheme({ - * colors: { - * modalBg: 'red' - * } - * }) - * - * function Example() { - * return - * } - * ``` - */ - theme?: "light" | "dark" | Theme; - className?: string; - /** - * The currency to use for the payment. - * @default "USD" - */ - currency?: SupportedFiatCurrency; - style?: React.CSSProperties; - /** - * Whether to show thirdweb branding in the widget. - * @default true - */ - showThirdwebBranding?: boolean; - /** - * Callback to be called when the swap is successful. - */ - onSuccess?: () => void; - /** - * Callback to be called when user encounters an error when swapping. - */ - onError?: (error: Error) => void; - /** - * Callback to be called when the user cancels the purchase. - */ - onCancel?: () => void; - connectOptions?: SwapWidgetConnectOptions; /** * The prefill Buy and/or Sell tokens for the swap widget. If `tokenAddress` is not provided, the native token will be used * @@ -153,6 +106,54 @@ export type SwapWidgetProps = { amount?: string; }; }; + /** + * Set the theme for the `SwapWidget` component. By default it is set to `"dark"` + * + * theme can be set to either `"dark"`, `"light"` or a custom theme object. + * You can also import [`lightTheme`](https://portal.thirdweb.com/references/typescript/v5/lightTheme) + * or [`darkTheme`](https://portal.thirdweb.com/references/typescript/v5/darkTheme) + * functions from `thirdweb/react` to use the default themes as base and overrides parts of it. + * @example + * ```ts + * import { lightTheme } from "thirdweb/react"; + * + * const customTheme = lightTheme({ + * colors: { + * modalBg: 'red' + * } + * }) + * + * function Example() { + * return + * } + * ``` + */ + theme?: "light" | "dark" | Theme; + /** + * The currency to use for the payment. + * @default "USD" + */ + currency?: SupportedFiatCurrency; + connectOptions?: SwapWidgetConnectOptions; + /** + * Whether to show thirdweb branding in the widget. + * @default true + */ + showThirdwebBranding?: boolean; + /** + * Callback to be called when the swap is successful. + */ + onSuccess?: (quote: SwapPreparedQuote) => void; + /** + * Callback to be called when user encounters an error when swapping. + */ + onError?: (error: Error, quote: SwapPreparedQuote) => void; + /** + * Callback to be called when the user cancels the purchase. + */ + onCancel?: (quote: SwapPreparedQuote) => void; + style?: React.CSSProperties; + className?: string; }; /** @@ -274,7 +275,7 @@ type SwapWidgetScreen = } | { id: "2:preview"; - preparedQuote: Extract; + preparedQuote: SwapPreparedQuote; request: BridgePrepareRequest; quote: Buy.quote.Result | Sell.quote.Result; buyToken: TokenWithPrices; @@ -286,7 +287,7 @@ type SwapWidgetScreen = id: "3:execute"; request: BridgePrepareRequest; quote: Buy.quote.Result | Sell.quote.Result; - preparedQuote: Extract; + preparedQuote: SwapPreparedQuote; buyToken: TokenWithPrices; sellToken: TokenWithPrices; sellTokenBalance: bigint; @@ -295,12 +296,13 @@ type SwapWidgetScreen = | { id: "4:success"; completedStatuses: CompletedStatusResult[]; - preparedQuote: Extract; + preparedQuote: SwapPreparedQuote; buyToken: TokenWithPrices; sellToken: TokenWithPrices; } | { id: "error"; + preparedQuote: SwapPreparedQuote; error: Error; }; @@ -377,11 +379,12 @@ function SwapWidgetContent(props: SwapWidgetProps) { useBridgeChains(props.client); const handleError = useCallback( - (error: Error) => { + (error: Error, quote: SwapPreparedQuote) => { console.error(error); - props.onError?.(error); + props.onError?.(error, quote); setScreen({ id: "error", + preparedQuote: quote, error, }); }, @@ -439,7 +442,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { id: "3:execute", }); }} - onError={handleError} + onError={(error) => handleError(error, screen.preparedQuote)} paymentMethod={{ quote: screen.quote, type: "wallet", @@ -471,9 +474,9 @@ function SwapWidgetContent(props: SwapWidgetProps) { sellTokenBalance: screen.sellTokenBalance, }); }} - onCancel={props.onCancel} + onCancel={() => props.onCancel?.(screen.preparedQuote)} onComplete={(completedStatuses) => { - props.onSuccess?.(); + props.onSuccess?.(screen.preparedQuote); setScreen({ ...screen, id: "4:success", @@ -519,7 +522,7 @@ function SwapWidgetContent(props: SwapWidgetProps) { error={screen.error} onCancel={() => { setScreen({ id: "1:swap-ui" }); - props.onCancel?.(); + props.onCancel?.(screen.preparedQuote); }} onRetry={() => { setScreen({ id: "1:swap-ui" }); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index 929b763397d..bff8390c087 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -30,10 +30,7 @@ import { type Theme, } from "../../../../core/design-system/index.js"; import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js"; -import type { - BridgePrepareRequest, - BridgePrepareResult, -} from "../../../../core/hooks/useBridgePrepare.js"; +import type { BridgePrepareRequest } from "../../../../core/hooks/useBridgePrepare.js"; import { ConnectButton } from "../../ConnectWallet/ConnectButton.js"; import { ArrowUpDownIcon } from "../../ConnectWallet/icons/ArrowUpDownIcon.js"; import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js"; @@ -51,6 +48,7 @@ import { DecimalRenderer } from "./common.js"; import { SelectToken } from "./select-token-ui.js"; import type { ActiveWalletInfo, + SwapPreparedQuote, SwapWidgetConnectOptions, TokenSelection, } from "./types.js"; @@ -65,8 +63,8 @@ type SwapUIProps = { currency: SupportedFiatCurrency; showThirdwebBranding: boolean; onSwap: (data: { - result: Extract; - request: Extract; + result: SwapPreparedQuote; + request: BridgePrepareRequest; buyToken: TokenWithPrices; sellTokenBalance: bigint; sellToken: TokenWithPrices; @@ -439,7 +437,7 @@ function useSwapQuote(params: { queryFn: async (): Promise< | { type: "preparedResult"; - result: Extract; + result: SwapPreparedQuote; request: Extract; } | { diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts index 63a7ab5b9e4..3012a5916b8 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/types.ts @@ -7,6 +7,7 @@ import type { SmartWalletOptions } from "../../../../../wallets/smart/types.js"; import type { AppMetadata } from "../../../../../wallets/types.js"; import type { SiweAuthOptions } from "../../../../core/hooks/auth/useSiweAuth.js"; import type { ConnectButton_connectModalOptions } from "../../../../core/hooks/connection/ConnectButtonProps.js"; +import type { BridgePrepareResult } from "../../../../core/hooks/useBridgePrepare.js"; /** * Connection options for the `SwapWidget` component @@ -149,3 +150,8 @@ export type TokenSelection = { tokenAddress: string; chainId: number; }; + +export type SwapPreparedQuote = Extract< + BridgePrepareResult, + { type: "buy" | "sell" } +>; From 1405e11d42e9c2ab0f60cfd4e14d6935e0d4de70 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Wed, 17 Sep 2025 18:29:34 +0530 Subject: [PATCH 32/36] add embed in chain, bridge page --- apps/dashboard/src/@/analytics/report.ts | 1 + .../components/blocks/BuyAndSwapEmbed.tsx} | 37 +++++++++------- .../blocks/grid-pattern-embed-container.tsx | 23 ++++++++++ .../components/client/BuyFundsSection.tsx | 24 +++++------ .../public-pages/erc20/erc20.tsx | 42 +++++++------------ .../client/UniversalBridgeEmbed.tsx | 23 +++++----- apps/dashboard/src/app/bridge/page.tsx | 14 +++---- .../src/react/web/ui/Bridge/FundWallet.tsx | 2 +- .../web/ui/Bridge/swap-widget/swap-ui.tsx | 2 +- 9 files changed, 94 insertions(+), 74 deletions(-) rename apps/dashboard/src/{app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx => @/components/blocks/BuyAndSwapEmbed.tsx} (81%) create mode 100644 apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts index 3c6a467b36b..34fcb17209f 100644 --- a/apps/dashboard/src/@/analytics/report.ts +++ b/apps/dashboard/src/@/analytics/report.ts @@ -255,6 +255,7 @@ type TokenSwapParams = { buyTokenAddress: string; sellTokenChainId: number; sellTokenAddress: string; + pageType: "asset" | "bridge" | "chain"; }; /** diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx b/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx similarity index 81% rename from apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx rename to apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx index ae5b054b816..2223150b7aa 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/_components/PayEmbedSection.tsx +++ b/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx @@ -16,10 +16,12 @@ import { cn } from "@/lib/utils"; import { parseError } from "@/utils/errorParser"; import { getSDKTheme } from "@/utils/sdk-component-theme"; -export function BuyTokenEmbed(props: { +export function BuyAndSwapEmbed(props: { client: ThirdwebClient; chain: Chain; - tokenAddress: string; + tokenAddress: string | undefined; + buyAmount: string | undefined; + pageType: "asset" | "bridge" | "chain"; }) { const { theme } = useTheme(); const [tab, setTab] = useState<"buy" | "swap">("swap"); @@ -41,7 +43,7 @@ export function BuyTokenEmbed(props: { {tab === "buy" && ( { const errorMessage = parseError(e); - reportAssetBuyFailed({ - assetType: "coin", - chainId: props.chain.id, - contractType: "DropERC20", - error: errorMessage, - }); + if (props.pageType === "asset") { + reportAssetBuyFailed({ + assetType: "coin", + chainId: props.chain.id, + contractType: "DropERC20", + error: errorMessage, + }); + } }} onSuccess={() => { - reportAssetBuySuccessful({ - assetType: "coin", - chainId: props.chain.id, - contractType: "DropERC20", - }); + if (props.pageType === "asset") { + reportAssetBuySuccessful({ + assetType: "coin", + chainId: props.chain.id, + contractType: "DropERC20", + }); + } }} theme={themeObj} tokenAddress={props.tokenAddress as `0x${string}`} @@ -92,6 +98,7 @@ export function BuyTokenEmbed(props: { buyTokenAddress: quote.intent.destinationTokenAddress, sellTokenChainId: quote.intent.originChainId, sellTokenAddress: quote.intent.originTokenAddress, + pageType: props.pageType, }); }} onSuccess={(quote) => { @@ -100,6 +107,7 @@ export function BuyTokenEmbed(props: { buyTokenAddress: quote.intent.destinationTokenAddress, sellTokenChainId: quote.intent.originChainId, sellTokenAddress: quote.intent.originTokenAddress, + pageType: props.pageType, }); }} onCancel={(quote) => { @@ -108,6 +116,7 @@ export function BuyTokenEmbed(props: { buyTokenAddress: quote.intent.destinationTokenAddress, sellTokenChainId: quote.intent.originChainId, sellTokenAddress: quote.intent.originTokenAddress, + pageType: props.pageType, }); }} /> diff --git a/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx b/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx new file mode 100644 index 00000000000..a7fbce837ba --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/grid-pattern-embed-container.tsx @@ -0,0 +1,23 @@ +import { GridPattern } from "@/components/ui/background-patterns"; + +export function GridPatternEmbedContainer(props: { + children: React.ReactNode; +}) { + return ( +
+ +
{props.children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/BuyFundsSection.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/BuyFundsSection.tsx index 97eae2dd01d..373594418a7 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/BuyFundsSection.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/BuyFundsSection.tsx @@ -1,24 +1,24 @@ "use client"; -import { useTheme } from "next-themes"; -import { defineChain, type ThirdwebClient } from "thirdweb"; +import type { ThirdwebClient } from "thirdweb"; import type { ChainMetadata } from "thirdweb/chains"; -import { BuyWidget } from "thirdweb/react"; -import { getSDKTheme } from "@/utils/sdk-component-theme"; +import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed"; +import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container"; +import { defineDashboardChain } from "@/lib/defineDashboardChain"; export function BuyFundsSection(props: { chain: ChainMetadata; client: ThirdwebClient; }) { - const { theme } = useTheme(); return ( -
- + -
+ ); } diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx index 82ddcda6d0b..11b8da95484 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/public-pages/erc20/erc20.tsx @@ -3,7 +3,8 @@ import type { ThirdwebContract } from "thirdweb"; import type { ChainMetadata } from "thirdweb/chains"; import { getContractMetadata } from "thirdweb/extensions/common"; import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20"; -import { GridPattern } from "@/components/ui/background-patterns"; +import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed"; +import { GridPatternEmbedContainer } from "@/components/blocks/grid-pattern-embed-container"; import { HAS_USED_DASHBOARD } from "@/constants/cookie"; import { resolveFunctionSelectors } from "@/lib/selectors"; import { AssetPageView } from "../_components/asset-page-view"; @@ -14,7 +15,6 @@ import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui"; import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics"; import { DexScreener } from "./_components/dex-screener"; import { mapChainIdToDexScreenerChainSlug } from "./_components/dex-screener-chains"; -import { BuyTokenEmbed } from "./_components/PayEmbedSection"; import { RecentTransfers } from "./_components/RecentTransfers"; import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info"; import { getCurrencyMeta } from "./_utils/getCurrencyMeta"; @@ -104,31 +104,17 @@ export async function ERC20PublicPage(props: { {showBuyEmbed && (
-
- + -
- -
-
{" "} +
)} @@ -185,10 +171,12 @@ function BuyEmbed(props: { }) { if (!props.claimConditionMeta) { return ( - ); } diff --git a/apps/dashboard/src/app/bridge/components/client/UniversalBridgeEmbed.tsx b/apps/dashboard/src/app/bridge/components/client/UniversalBridgeEmbed.tsx index 366b4d400d4..fe333175adb 100644 --- a/apps/dashboard/src/app/bridge/components/client/UniversalBridgeEmbed.tsx +++ b/apps/dashboard/src/app/bridge/components/client/UniversalBridgeEmbed.tsx @@ -1,10 +1,8 @@ "use client"; -import { useTheme } from "next-themes"; -import type { Address } from "thirdweb"; -import { BuyWidget, type TokenInfo } from "thirdweb/react"; +import type { TokenInfo } from "thirdweb/react"; +import { BuyAndSwapEmbed } from "@/components/blocks/BuyAndSwapEmbed"; import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; -import { getSDKTheme } from "@/utils/sdk-component-theme"; import { bridgeAppThirdwebClient } from "../../constants"; export function UniversalBridgeEmbed({ @@ -16,16 +14,17 @@ export function UniversalBridgeEmbed({ token: TokenInfo | undefined; amount?: string; }) { - const { theme } = useTheme(); const chain = useV5DashboardChain(chainId || 1); return ( - +
+ +
); } diff --git a/apps/dashboard/src/app/bridge/page.tsx b/apps/dashboard/src/app/bridge/page.tsx index 12eddf3ce08..6930e98679c 100644 --- a/apps/dashboard/src/app/bridge/page.tsx +++ b/apps/dashboard/src/app/bridge/page.tsx @@ -49,7 +49,7 @@ export default async function BridgePage({ } return ( -
+
@@ -79,17 +79,17 @@ export default async function BridgePage({
-

- Get Started with thirdweb Payments +

+ Get Started with thirdweb Bridge

- Simple, instant, and secure payments across any token and - chain. + thirdweb Bridge allows developers to easily bridge and swap + tokens between chains and wallets.

diff --git a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx index c5c80544152..f1d3171e3c2 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx @@ -360,7 +360,7 @@ export function FundWallet({ {showThirdwebBranding ? (
- +
) : null} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx index bff8390c087..8212e230a74 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/swap-ui.tsx @@ -398,7 +398,7 @@ export function SwapUI(props: SwapUIProps) { {props.showThirdwebBranding ? (
- +
) : null} From f0c4fc817767c0e7a7e4ca0e32dd372e4ce6f304 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Wed, 17 Sep 2025 19:34:44 +0530 Subject: [PATCH 33/36] cleanup --- apps/dashboard/src/@/analytics/report.ts | 20 ++++++ .../@/components/blocks/BuyAndSwapEmbed.tsx | 13 +++- .../web/ui/Bridge/swap-widget/SwapWidget.tsx | 62 +++++++------------ 3 files changed, 54 insertions(+), 41 deletions(-) diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts index 34fcb17209f..07792e92dc0 100644 --- a/apps/dashboard/src/@/analytics/report.ts +++ b/apps/dashboard/src/@/analytics/report.ts @@ -320,6 +320,26 @@ export function reportAssetBuyFailed(properties: { }); } +/** + * ### Why do we need to report this event? + * - To track number of cancelled asset purchases from the token page + * - To track the errors that users encounter when trying to purchase an asset + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportAssetBuyCancelled(properties: { + chainId: number; + contractType: AssetContractType; + assetType: "nft" | "coin"; +}) { + posthog.capture("asset buy cancelled", { + assetType: properties.assetType, + chainId: properties.chainId, + contractType: properties.contractType, + }); +} + // Assets Landing Page ---------------------------- /** diff --git a/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx b/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx index 2223150b7aa..55f2e9603e0 100644 --- a/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx +++ b/apps/dashboard/src/@/components/blocks/BuyAndSwapEmbed.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import type { Chain, ThirdwebClient } from "thirdweb"; import { BuyWidget, SwapWidget } from "thirdweb/react"; import { + reportAssetBuyCancelled, reportAssetBuyFailed, reportAssetBuySuccessful, reportTokenSwapCancelled, @@ -62,6 +63,15 @@ export function BuyAndSwapEmbed(props: { }); } }} + onCancel={() => { + if (props.pageType === "asset") { + reportAssetBuyCancelled({ + assetType: "coin", + chainId: props.chain.id, + contractType: "DropERC20", + }); + } + }} onSuccess={() => { if (props.pageType === "asset") { reportAssetBuySuccessful({ @@ -92,8 +102,9 @@ export function BuyAndSwapEmbed(props: { }, }} onError={(error, quote) => { + const errorMessage = parseError(error); reportTokenSwapFailed({ - errorMessage: error.message, + errorMessage: errorMessage, buyTokenChainId: quote.intent.destinationChainId, buyTokenAddress: quote.intent.destinationTokenAddress, sellTokenChainId: quote.intent.originChainId, diff --git a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx index 7b3a7eb47ea..cd9989c91bc 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/swap-widget/SwapWidget.tsx @@ -263,48 +263,30 @@ export function SwapWidgetContainer(props: { ); } +type SelectionInfo = { + preparedQuote: SwapPreparedQuote; + request: BridgePrepareRequest; + quote: Buy.quote.Result | Sell.quote.Result; + buyToken: TokenWithPrices; + sellToken: TokenWithPrices; + sellTokenBalance: bigint; + mode: "buy" | "sell"; +}; + +type Join = T & U; + type SwapWidgetScreen = | { id: "1:swap-ui" } - | { - id: "2:loading-quote"; - quote: Buy.quote.Result | Sell.quote.Result; - buyToken: TokenWithPrices; - sellToken: TokenWithPrices; - sellTokenBalance: bigint; - mode: "buy" | "sell"; - } - | { - id: "2:preview"; - preparedQuote: SwapPreparedQuote; - request: BridgePrepareRequest; - quote: Buy.quote.Result | Sell.quote.Result; - buyToken: TokenWithPrices; - sellToken: TokenWithPrices; - sellTokenBalance: bigint; - mode: "buy" | "sell"; - } - | { - id: "3:execute"; - request: BridgePrepareRequest; - quote: Buy.quote.Result | Sell.quote.Result; - preparedQuote: SwapPreparedQuote; - buyToken: TokenWithPrices; - sellToken: TokenWithPrices; - sellTokenBalance: bigint; - mode: "buy" | "sell"; - } - | { - id: "4:success"; - completedStatuses: CompletedStatusResult[]; - preparedQuote: SwapPreparedQuote; - buyToken: TokenWithPrices; - sellToken: TokenWithPrices; - } - | { - id: "error"; - preparedQuote: SwapPreparedQuote; - error: Error; - }; + | Join<{ id: "2:preview" }, SelectionInfo> + | Join<{ id: "3:execute" }, SelectionInfo> + | Join< + { + id: "4:success"; + completedStatuses: CompletedStatusResult[]; + }, + SelectionInfo + > + | { id: "error"; error: Error; preparedQuote: SwapPreparedQuote }; function SwapWidgetContent(props: SwapWidgetProps) { const [screen, setScreen] = useState({ id: "1:swap-ui" }); From 43b06e11089a09e911bb1fe68647df40fb23156b Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Wed, 17 Sep 2025 19:37:30 +0530 Subject: [PATCH 34/36] add changeset --- .changeset/lucky-turtles-smell.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/lucky-turtles-smell.md diff --git a/.changeset/lucky-turtles-smell.md b/.changeset/lucky-turtles-smell.md new file mode 100644 index 00000000000..70616d70091 --- /dev/null +++ b/.changeset/lucky-turtles-smell.md @@ -0,0 +1,9 @@ +--- +"thirdweb": patch +--- + +Add `SwapWidget` component for swapping tokens using thirdweb Bridge + +```tsx + +``` From d87c23b4329b66086f1cb3c14306066bf85f9a18 Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Wed, 17 Sep 2025 21:39:17 +0530 Subject: [PATCH 35/36] fix build --- .../playground-web/src/app/payments/components/LeftSection.tsx | 3 ++- apps/playground-web/src/app/payments/components/types.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/playground-web/src/app/payments/components/LeftSection.tsx b/apps/playground-web/src/app/payments/components/LeftSection.tsx index f61fb4ca55f..0bcc4dc72e3 100644 --- a/apps/playground-web/src/app/payments/components/LeftSection.tsx +++ b/apps/playground-web/src/app/payments/components/LeftSection.tsx @@ -27,6 +27,7 @@ import { import { TokenSelector } from "@/components/ui/TokenSelector"; import { THIRDWEB_CLIENT } from "@/lib/client"; import type { TokenMetadata } from "@/lib/types"; +import type { SupportedFiatCurrency } from "../../../../../../packages/thirdweb/dist/types/pay/convert/type"; import { CollapsibleSection } from "../../wallets/sign-in/components/CollapsibleSection"; import { ColorFormGroup } from "../../wallets/sign-in/components/ColorFormGroup"; import type { BridgeComponentsPlaygroundOptions } from "./types"; @@ -155,7 +156,7 @@ export function LeftSection(props: { ...v, payOptions: { ...v.payOptions, - currency: value, + currency: value as SupportedFiatCurrency, }, })); }} diff --git a/apps/playground-web/src/app/payments/components/types.ts b/apps/playground-web/src/app/payments/components/types.ts index c509a255042..6b8f6b57137 100644 --- a/apps/playground-web/src/app/payments/components/types.ts +++ b/apps/playground-web/src/app/payments/components/types.ts @@ -28,7 +28,7 @@ const CURRENCIES = [ "ISK", ] as const; -type SupportedFiatCurrency = (typeof CURRENCIES)[number] | (string & {}); +type SupportedFiatCurrency = (typeof CURRENCIES)[number]; export type BridgeComponentsPlaygroundOptions = { theme: { From b1f23a255aefbb2499be7a5cd9eec4e1c7bbcc4c Mon Sep 17 00:00:00 2001 From: Manan Tank Date: Wed, 17 Sep 2025 21:56:44 +0530 Subject: [PATCH 36/36] update changeset version --- .changeset/lucky-turtles-smell.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/lucky-turtles-smell.md b/.changeset/lucky-turtles-smell.md index 70616d70091..c41721882da 100644 --- a/.changeset/lucky-turtles-smell.md +++ b/.changeset/lucky-turtles-smell.md @@ -1,5 +1,5 @@ --- -"thirdweb": patch +"thirdweb": minor --- Add `SwapWidget` component for swapping tokens using thirdweb Bridge