diff --git a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx index a0490e05a6e..eaddb5105bf 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/FundWallet.tsx @@ -213,6 +213,8 @@ export function FundWallet(props: FundWalletProps) { > - + {ensNameQuery.data || shortenAddress(props.address)} 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 6f85d72654f..756a2bbbdd4 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 @@ -13,7 +13,7 @@ import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet. import { useConnectedWallets } from "../../../../core/hooks/wallets/useConnectedWallets.js"; import type { SupportedTokens } from "../../../../core/utils/defaultTokens.js"; import type { ConnectLocale } from "../../ConnectWallet/locale/types.js"; -import { WalletSwitcherConnectionScreen } from "../../ConnectWallet/screens/WalletSwitcherConnectionScreen.js"; +import { WalletConnectionScreen } from "../../ConnectWallet/screens/WalletSwitcherConnectionScreen.js"; import { Container, ModalHeader } from "../../components/basic.js"; import { Spacer } from "../../components/Spacer.js"; import type { PayEmbedConnectOptions } from "../../PayEmbed.js"; @@ -239,7 +239,9 @@ export function PaymentSelection({ : connectOptions?.chains; return ( - void; client: ThirdwebClient; - onClick: () => void; }) { + if (props.selectedTab.type === "your-tokens") { + return ; + } + + if (props.selectedTab.type === "chain") { + return ( +
+ + + + + +
+ ); + } + + return null; +} + +function Tabs(props: { + selectedTab: SelectedTab; + onSelect: (tab: "your-tokens" | "all-tokens") => void; +}) { + const theme = useCustomTheme(); return ( - - - + {props.isSelected && ( +
+ )} + ); } 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 4a5e2db5ff0..7cd0117d7f9 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 @@ -8,6 +8,7 @@ import { radius, spacing, } from "../../../../core/design-system/index.js"; +import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js"; import { Container, Line, ModalHeader } from "../../components/basic.js"; import { Button } from "../../components/buttons.js"; import { Img } from "../../components/Img.js"; @@ -15,14 +16,15 @@ import { Skeleton } from "../../components/Skeleton.js"; import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; import { SearchInput } from "./SearchInput.js"; +import type { SelectedTab } from "./types.js"; import { useBridgeChainsWithFilters } from "./use-bridge-chains.js"; import { cleanedChainName } from "./utils.js"; type SelectBuyTokenProps = { onBack: () => void; client: ThirdwebClient; - onSelectChain: (chain: BridgeChain) => void; - selectedChain: BridgeChain | undefined; + onSelectTab: (tab: SelectedTab) => void; + selectedTab: SelectedTab; isMobile: boolean; type: "buy" | "sell"; selections: { @@ -46,7 +48,7 @@ export function SelectBridgeChain(props: SelectBuyTokenProps) { ); @@ -59,12 +61,14 @@ export function SelectBridgeChainUI( props: SelectBuyTokenProps & { isPending: boolean; chains: BridgeChain[]; - onSelectChain: (chain: BridgeChain) => void; - selectedChain: BridgeChain | undefined; + onSelectTab: (tab: SelectedTab) => void; + selectedTab: SelectedTab; }, ) { const [search, setSearch] = useState(""); - const [initiallySelectedChain] = useState(props.selectedChain); + const [initiallySelectedChain] = useState( + props.selectedTab?.type === "chain" ? props.selectedTab.chain : undefined, + ); // put the initially selected chain first const sortedChains = useMemo(() => { @@ -84,7 +88,13 @@ export function SelectBridgeChainUI( }); return ( - + {props.isMobile && ( <> @@ -96,6 +106,7 @@ export function SelectBridgeChainUI( + {/* search */} - + + {/* scroll container */} - {filteredChains.map((chain) => ( - props.onSelectChain(chain)} - isSelected={chain.chainId === props.selectedChain?.chainId} - isMobile={props.isMobile} - /> - ))} - - {props.isPending && - new Array(20).fill(0).map(() => ( - // biome-ignore lint/correctness/useJsxKeyInIterable: ok - - ))} + {/* chains label */} + {!props.isMobile && ( + <> + {/* your tokens button */} + + props.onSelectTab({ type: "your-tokens" })} + isSelected={props.selectedTab?.type === "your-tokens"} + /> + + + - {filteredChains.length === 0 && !props.isPending && ( -
- - No chains found for "{search}" - -
+ + + Chains + + + + + )} + + {/* chains list */} + + {filteredChains.map((chain) => ( + props.onSelectTab({ type: "chain", chain })} + isSelected={ + props.selectedTab?.type === "chain" && + chain.chainId === props.selectedTab.chain.chainId + } + /> + ))} + + {props.isPending && + new Array(20).fill(0).map(() => ( + // biome-ignore lint/correctness/useJsxKeyInIterable: ok + + ))} + + {filteredChains.length === 0 && !props.isPending && ( +
+ + No chains found + +
+ )} +
); } -function ChainButtonSkeleton(props: { isMobile: boolean }) { - const iconSizeValue = props.isMobile ? iconSize.lg : iconSize.md; +function ChainButtonSkeleton() { + const iconSizeValue = iconSize.md; return (
- +
); } @@ -185,10 +222,9 @@ function ChainButton(props: { client: ThirdwebClient; onClick: () => void; isSelected: boolean; - isMobile: boolean; }) { const theme = useCustomTheme(); - const iconSizeValue = props.isMobile ? iconSize.lg : iconSize.md; + const iconSizeValue = iconSize.md; 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 faf99fbdb7a..1eb8dae321d 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 @@ -17,7 +17,9 @@ import { import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js"; import { InfoIcon } from "../../ConnectWallet/icons/InfoIcon.js"; import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js"; +import connectLocaleEn from "../../ConnectWallet/locale/en.js"; import { formatCurrencyAmount } from "../../ConnectWallet/screens/formatTokenBalance.js"; +import { WalletConnectionScreen } from "../../ConnectWallet/screens/WalletSwitcherConnectionScreen.js"; import { Container, Line, @@ -36,16 +38,17 @@ import { useDebouncedValue } from "../../hooks/useDebouncedValue.js"; import { useIsMobile } from "../../hooks/useisMobile.js"; import { useTokenPrice } from "./hooks.js"; import { SearchInput } from "./SearchInput.js"; -import { SelectChainButton } from "./SelectChainButton.js"; +import { MobileTabSelector } from "./SelectChainButton.js"; +import type { SwapWidgetProps } from "./SwapWidget.js"; import { SelectBridgeChain } from "./select-chain.js"; -import type { ActiveWalletInfo, TokenSelection } from "./types.js"; +import type { ActiveWalletInfo, SelectedTab, TokenSelection } from "./types.js"; import { useBridgeChainsWithFilters } from "./use-bridge-chains.js"; import { type TokenBalance, useTokenBalances, useTokens, } from "./use-tokens.js"; -import { tokenAmountFormatter } from "./utils.js"; +import { cleanedChainName, tokenAmountFormatter } from "./utils.js"; /** * @internal @@ -62,6 +65,8 @@ type SelectTokenUIProps = { sellChainId: number | undefined; }; currency: SupportedFiatCurrency; + theme: SwapWidgetProps["theme"]; + connectOptions: SwapWidgetProps["connectOptions"]; }; function findChain(chains: BridgeChain[], activeChainId: number | undefined) { @@ -73,9 +78,6 @@ function findChain(chains: BridgeChain[], activeChainId: number | undefined) { const INITIAL_LIMIT = 100; -/** - * @internal - */ export function SelectToken(props: SelectTokenUIProps) { const chainQuery = useBridgeChainsWithFilters({ client: props.client, @@ -84,142 +86,80 @@ export function SelectToken(props: SelectTokenUIProps) { sellChainId: props.selections.sellChainId, }); - const [search, _setSearch] = useState(""); - const debouncedSearch = useDebouncedValue(search, 500); - const [limit, setLimit] = useState(INITIAL_LIMIT); - - const setSearch = useCallback((search: string) => { - _setSearch(search); - setLimit(INITIAL_LIMIT); - }, []); - - const [_selectedChain, setSelectedChain] = useState( - undefined, - ); - const selectedChain = - _selectedChain || - (chainQuery.data - ? findChain(chainQuery.data, props.selectedToken?.chainId) || - findChain(chainQuery.data, props.activeWalletInfo?.activeChain.id) || - findChain(chainQuery.data, 1) - : undefined); - - // all tokens - const tokensQuery = useTokens({ - client: props.client, - chainId: selectedChain?.chainId, - search: debouncedSearch, - limit, - offset: 0, - }); - - // owned tokens - const ownedTokensQuery = useTokenBalances({ - client: props.client, - chainId: selectedChain?.chainId, - limit, - page: 1, - walletAddress: props.activeWalletInfo?.activeAccount.address, - }); - - const filteredOwnedTokens = useMemo(() => { - return ownedTokensQuery.data?.tokens?.filter((token) => { - return ( - token.symbol.toLowerCase().includes(debouncedSearch.toLowerCase()) || - token.name.toLowerCase().includes(debouncedSearch.toLowerCase()) || - token.token_address - .toLowerCase() - .includes(debouncedSearch.toLowerCase()) - ); - }); - }, [ownedTokensQuery.data?.tokens, debouncedSearch]); + if (chainQuery.isPending) { + return ( + + + + ); + } - const isFetching = tokensQuery.isFetching || ownedTokensQuery.isFetching; + if (!chainQuery.data) { + return ( + + + Failed to fetch chains + + + ); + } - return ( - { - setLimit(limit + INITIAL_LIMIT); - } - : undefined - } - /> - ); + return ; } function SelectTokenUI( props: SelectTokenUIProps & { - ownedTokens: TokenBalance[]; - allTokens: Token[]; - isFetching: boolean; - selectedChain: BridgeChain | undefined; - setSelectedChain: (chain: BridgeChain) => void; - search: string; - setSearch: (search: string) => void; - selectedToken: TokenSelection | undefined; - setSelectedToken: (token: TokenSelection) => void; - showMore: (() => void) | undefined; - type: "buy" | "sell"; - selections: { - buyChainId: number | undefined; - sellChainId: number | undefined; - }; - }, + chains: BridgeChain[]; + } ) { - const isMobile = useIsMobile(); - const [screen, setScreen] = useState<"select-chain" | "select-token">( - "select-token", - ); - - // show tokens with icons first - const sortedOwnedTokens = useMemo(() => { - return props.ownedTokens.sort((a, b) => { - if (a.icon_uri && !b.icon_uri) { - return -1; - } - if (!a.icon_uri && b.icon_uri) { - return 1; + const [selectedTab, setSelectedTab] = useState(() => { + if (props.selectedToken) { + const chain = findChain(props.chains, props.selectedToken?.chainId); + if (chain) { + return { type: "chain", chain }; } - return 0; - }); - }, [props.ownedTokens]); + } + return { type: "your-tokens" }; + }); - const otherTokens = useMemo(() => { - const ownedTokenSet = new Set( - sortedOwnedTokens.map((t) => - `${t.token_address}-${t.chain_id}`.toLowerCase(), - ), - ); - return props.allTokens.filter( - (token) => - !ownedTokenSet.has(`${token.address}-${token.chainId}`.toLowerCase()), - ); - }, [props.allTokens, sortedOwnedTokens]); + const isMobile = useIsMobile(); + const [screen, setScreen] = useState< + "select-chain" | "select-token" | "connect-wallet" + >("select-token"); - // show tokens with icons first - const sortedOtherTokens = useMemo(() => { - return otherTokens.sort((a, b) => { - if (a.iconUri && !b.iconUri) { - return -1; - } - if (!a.iconUri && b.iconUri) { - return 1; - } - return 0; - }); - }, [otherTokens]); + if (screen === "connect-wallet") { + return ( + { + setScreen("select-token"); + }} + isEmbed={false} + accountAbstraction={props.connectOptions?.accountAbstraction} + onSelect={() => { + setScreen("select-token"); + }} + /> + ); + } // desktop if (!isMobile) { @@ -238,58 +178,69 @@ function SelectTokenUI( onBack={() => setScreen("select-token")} client={props.client} isMobile={false} - onSelectChain={(chain) => { - props.setSelectedChain(chain); + onSelectTab={(tab) => { + setSelectedTab(tab); setScreen("select-token"); }} - selectedChain={props.selectedChain} + selectedTab={selectedTab} /> { + setScreen("connect-wallet"); + }} + activeWalletInfo={props.activeWalletInfo} onSelectToken={(token) => { props.setSelectedToken(token); props.onClose(); }} isMobile={false} - key={props.selectedChain?.chainId} + key={ + selectedTab?.type === "chain" + ? selectedTab.chain.chainId + : undefined + } selectedToken={props.selectedToken} - isFetching={props.isFetching} - ownedTokens={props.ownedTokens} - otherTokens={sortedOtherTokens} - showMore={props.showMore} - selectedChain={props.selectedChain} - onSelectChain={() => setScreen("select-chain")} + selectedTab={selectedTab} + onShowChainSelector={() => setScreen("select-chain")} client={props.client} - search={props.search} - setSearch={props.setSearch} currency={props.currency} + theme={props.theme} + connectOptions={props.connectOptions} />
); } + // mobile if (screen === "select-token") { return ( { + setScreen("connect-wallet"); + }} + activeWalletInfo={props.activeWalletInfo} + key={ + selectedTab?.type === "chain" ? selectedTab.chain.chainId : undefined + } onSelectToken={(token) => { props.setSelectedToken(token); props.onClose(); }} selectedToken={props.selectedToken} - isFetching={props.isFetching} - ownedTokens={props.ownedTokens} - otherTokens={sortedOtherTokens} - showMore={props.showMore} - selectedChain={props.selectedChain} + selectedTab={selectedTab} isMobile={true} - onSelectChain={() => setScreen("select-chain")} + onShowChainSelector={() => setScreen("select-chain")} client={props.client} - search={props.search} - setSearch={props.setSearch} currency={props.currency} + theme={props.theme} + connectOptions={props.connectOptions} /> ); } @@ -300,11 +251,11 @@ function SelectTokenUI( isMobile={true} onBack={() => setScreen("select-token")} client={props.client} - onSelectChain={(chain) => { - props.setSelectedChain(chain); + onSelectTab={(tab) => { + setSelectedTab(tab); setScreen("select-token"); }} - selectedChain={props.selectedChain} + selectedTab={selectedTab} type={props.type} selections={props.selections} /> @@ -321,11 +272,16 @@ function TokenButtonSkeleton() { display: "flex", alignItems: "center", gap: spacing.sm, - padding: `${spacing.xs} ${spacing.xs}`, - height: "70px", + padding: spacing.xs, }} > - +
@@ -365,7 +321,7 @@ function TokenButton(props: { fontWeight: 500, fontSize: fontSize.md, border: "1px solid transparent", - padding: `${spacing.xs} ${spacing.xs}`, + padding: spacing.xs, textAlign: "left", lineHeight: "1.5", borderRadius: radius.lg, @@ -437,8 +393,8 @@ function TokenButton(props: { {tokenAmountFormatter.format( Number( - toTokens(BigInt(props.token.balance), props.token.decimals), - ), + toTokens(BigInt(props.token.balance), props.token.decimals) + ) )} )} @@ -489,6 +445,180 @@ function TokenButton(props: { ); } +function YourTokenButton(props: { + token: TokenBalance; + chain: BridgeChain | undefined; + client: ThirdwebClient; + onSelect: (tokenWithPrices: TokenSelection) => void; + onInfoClick: (tokenAddress: string, chainId: number) => void; + isSelected: boolean; +}) { + const theme = useCustomTheme(); + const tokenBalanceInUnits = toTokens( + BigInt(props.token.balance), + props.token.decimals + ); + const usdValue = + props.token.price_data.price_usd * Number(tokenBalanceInUnits); + + const tokenAddress = props.token.token_address; + const chainId = props.token.chain_id; + + return ( + + ); +} + function TokenInfoScreen(props: { tokenAddress: string; chainId: number; @@ -593,7 +723,7 @@ function TokenInfoScreen(props: { token.prices[props.currency] ? formatCurrencyAmount( props.currency, - token.prices[props.currency] as number, + token.prices[props.currency] as number ) : "N/A" } @@ -686,29 +816,27 @@ function TokenInfoRow(props: { label: string; value: string }) { } function TokenSelectionScreen(props: { - selectedChain: BridgeChain | undefined; + selectedTab: SelectedTab; isMobile: boolean; - onSelectChain: () => void; client: ThirdwebClient; - search: string; - setSearch: (search: string) => void; - isFetching: boolean; - ownedTokens: TokenBalance[]; - otherTokens: Token[]; - showMore: (() => void) | undefined; selectedToken: TokenSelection | undefined; onSelectToken: (token: TokenSelection) => void; currency: SupportedFiatCurrency; + onShowChainSelector: () => void; + activeWalletInfo: ActiveWalletInfo | undefined; + theme: SwapWidgetProps["theme"]; + connectOptions: SwapWidgetProps["connectOptions"]; + onConnectWallet: () => void; + setSelectedTab: (tab: SelectedTab) => void; + chains: BridgeChain[]; }) { - const [tokenInfoScreen, setTokenInfoScreen] = useState<{ - tokenAddress: string; - chainId: number; - } | null>(null); - - const noTokensFound = - !props.isFetching && - props.otherTokens.length === 0 && - props.ownedTokens.length === 0; + const [tokenInfoScreen, setTokenInfoScreen] = useState< + | { + tokenAddress: string; + chainId: number; + } + | undefined + >(); if (tokenInfoScreen) { return ( @@ -716,209 +844,547 @@ function TokenSelectionScreen(props: { tokenAddress={tokenInfoScreen.tokenAddress} chainId={tokenInfoScreen.chainId} client={props.client} - onBack={() => setTokenInfoScreen(null)} + onBack={() => setTokenInfoScreen(undefined)} currency={props.currency} /> ); } return ( - - - - Select Token - - - - Select a token from the list or use the search - + + + + {/* tab switcher */} + {props.isMobile ? ( + + { + if (value === "your-tokens") { + props.setSelectedTab({ type: "your-tokens" }); + } + if (value === "all-tokens") { + const chain = props.selectedToken + ? findChain(props.chains, props.selectedToken.chainId) + : props.chains[0]; + if (chain) { + props.setSelectedTab({ type: "chain", chain: chain }); + } + } + if (value === "chain-selector") { + props.onShowChainSelector(); + } + }} + /> + + ) : ( + + )} + + {props.selectedTab.type === "chain" && ( + + )} + + {props.selectedTab.type === "your-tokens" && ( + + )} + + ); +} + +function ChainTokenSelectionScreen(props: { + selectedTab: SelectedTab; + setSelectedTab: (tab: SelectedTab) => void; + isMobile: boolean; + client: ThirdwebClient; + chains: BridgeChain[]; + selectedToken: TokenSelection | undefined; + onSelectToken: (token: TokenSelection) => void; + currency: SupportedFiatCurrency; + onShowChainSelector: () => void; + activeWalletInfo: ActiveWalletInfo | undefined; + showTokenInfo: { tokenAddress: string; chainId: number } | undefined; + setShowTokenInfo: ( + showTokenInfo: { tokenAddress: string; chainId: number } | undefined + ) => void; +}) { + const [limit, setLimit] = useState(INITIAL_LIMIT); + const [search, _setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 500); + const setSearch = useCallback((search: string) => { + _setSearch(search); + setLimit(INITIAL_LIMIT); + }, []); + + const allTokensQuery = useTokens({ + client: props.client, + chainId: + props.selectedTab?.type === "chain" + ? props.selectedTab.chain.chainId + : undefined, + search: debouncedSearch, + limit, + offset: 0, + }); + + const ownedTokensQuery = useTokenBalances({ + client: props.client, + chainId: + props.selectedTab?.type === "chain" + ? props.selectedTab.chain.chainId + : undefined, + limit, + page: 1, + walletAddress: props.activeWalletInfo?.activeAccount.address, + }); + + const isFetching = allTokensQuery.isFetching || ownedTokensQuery.isFetching; + + const ownedTokens = useMemo(() => { + if (!ownedTokensQuery.data || ownedTokensQuery.data?.tokens?.length === 0) { + return []; + } + + let tokens = ownedTokensQuery.data.tokens; + + if (debouncedSearch) { + tokens = tokens.filter((token) => { + return ( + token.symbol.toLowerCase().includes(debouncedSearch.toLowerCase()) || + token.name.toLowerCase().includes(debouncedSearch.toLowerCase()) || + token.token_address + .toLowerCase() + .includes(debouncedSearch.toLowerCase()) + ); + }); + } + + tokens = tokens.sort((a, b) => { + if (a.icon_uri && !b.icon_uri) { + return -1; + } + if (!a.icon_uri && b.icon_uri) { + return 1; + } + return 0; + }); + + return tokens; + }, [ownedTokensQuery.data?.tokens, debouncedSearch, ownedTokensQuery.data]); + + const otherTokens = useMemo(() => { + if (!allTokensQuery.data || allTokensQuery.data?.length === 0) { + return []; + } + + const ownedTokenSet = new Set( + ownedTokens.map((t) => `${t.token_address}-${t.chain_id}`.toLowerCase()) + ); + + let tokens = allTokensQuery.data.filter( + (token) => + !ownedTokenSet.has(`${token.address}-${token.chainId}`.toLowerCase()) + ); + + tokens = tokens.sort((a, b) => { + if (a.iconUri && !b.iconUri) { + return -1; + } + if (!a.iconUri && b.iconUri) { + return 1; + } + return 0; + }); + + return tokens; + }, [allTokensQuery.data, ownedTokens]); + + const showMore = useCallback(() => { + setLimit(limit + INITIAL_LIMIT); + }, [limit]); + + const showLoadMoreButton = allTokensQuery.data?.length === limit; + + const noTokensFound = + !isFetching && otherTokens.length === 0 && ownedTokens.length === 0; + + return ( + + {/* search */} + + - {!props.selectedChain && ( + + + {/* tokens for a chain */} + {props.selectedTab?.type === "chain" && ( - - - )} + {isFetching && + new Array(20).fill(0).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ok + + ))} - {props.selectedChain && ( - <> - {props.isMobile ? ( - - 0 && ( + + + + Your Tokens + + + )} + + {!isFetching && + ownedTokens.map((token) => ( + + props.setShowTokenInfo({ tokenAddress, chainId }) + } + isSelected={ + !!props.selectedToken && + props.selectedToken.tokenAddress.toLowerCase() === + token.token_address.toLowerCase() && + token.chain_id === props.selectedToken.chainId + } /> + ))} + + {!isFetching && ownedTokens.length > 0 && ( + + + + Other Tokens + - ) : ( - )} - {/* search */} - - - + {!isFetching && + otherTokens.map((token) => ( + + props.setShowTokenInfo({ tokenAddress, chainId }) + } + isSelected={ + !!props.selectedToken && + props.selectedToken.tokenAddress.toLowerCase() === + token.address.toLowerCase() && + token.chainId === props.selectedToken.chainId + } + /> + ))} - + {showLoadMoreButton && ( + + )} - - {props.isFetching && - new Array(20).fill(0).map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: ok - - ))} + {noTokensFound && ( +
+ + No Tokens Found + +
+ )} +
+ )} +
+ ); +} - {!props.isFetching && props.ownedTokens.length > 0 && ( - - - - Your Tokens - - - )} +function YourTokenSelectionScreen(props: { + client: ThirdwebClient; + selectedToken: TokenSelection | undefined; + onSelectToken: (token: TokenSelection) => void; + currency: SupportedFiatCurrency; + onShowChainSelector: () => void; + activeWalletInfo: ActiveWalletInfo | undefined; + theme: SwapWidgetProps["theme"]; + connectOptions: SwapWidgetProps["connectOptions"]; + onConnectWallet: () => void; + isMobile: boolean; + setSelectedTab: (tab: SelectedTab) => void; + selectedTab: SelectedTab; + showTokenInfo: { tokenAddress: string; chainId: number } | undefined; + setShowTokenInfo: ( + showTokenInfo: { tokenAddress: string; chainId: number } | undefined + ) => void; + chains: BridgeChain[]; +}) { + const [search, _setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 500); + const setSearch = useCallback((value: string) => { + _setSearch(value); + }, []); - {!props.isFetching && - props.ownedTokens.map((token) => ( - - setTokenInfoScreen({ tokenAddress, chainId }) - } - isSelected={ - !!props.selectedToken && - props.selectedToken.tokenAddress.toLowerCase() === - token.token_address.toLowerCase() && - token.chain_id === props.selectedToken.chainId - } - /> - ))} + const allTokensQuery = useTokenBalances({ + client: props.client, + limit: 100, + page: 1, + walletAddress: props.activeWalletInfo?.activeAccount.address, + chainId: props.chains.map((chain) => chain.chainId), // TODO - this is not working!! + }); - {!props.isFetching && props.ownedTokens.length > 0 && ( - - - - Other Tokens - - - )} + const chainMap = useMemo(() => { + const map = new Map(); + for (const chain of props.chains) { + map.set(chain.chainId, chain); + } + return map; + }, [props.chains]); - {!props.isFetching && - props.otherTokens.map((token) => ( - - setTokenInfoScreen({ tokenAddress, chainId }) - } - isSelected={ - !!props.selectedToken && - props.selectedToken.tokenAddress.toLowerCase() === - token.address.toLowerCase() && - token.chainId === props.selectedToken.chainId - } - /> - ))} + const filteredTokens = useMemo(() => { + if (!allTokensQuery.data?.tokens) { + return []; + } - {props.showMore && ( - - )} + let tokens = allTokensQuery.data.tokens; - {noTokensFound && ( -
- - No Tokens Found - -
- )} -
- - )} + if (debouncedSearch) { + const searchLower = debouncedSearch.toLowerCase(); + tokens = tokens.filter((token) => { + const chain = chainMap.get(token.chain_id); + const chainName = chain?.name.toLowerCase() || ""; + return ( + token.symbol.toLowerCase().includes(searchLower) || + token.name.toLowerCase().includes(searchLower) || + token.token_address.toLowerCase().includes(searchLower) || + chainName.includes(searchLower) + ); + }); + } + + // Sort by tokens with icons first + tokens = tokens.sort((a, b) => { + if (a.icon_uri && !b.icon_uri) { + return -1; + } + if (!a.icon_uri && b.icon_uri) { + return 1; + } + return 0; + }); + + return tokens; + }, [allTokensQuery.data?.tokens, debouncedSearch, chainMap]); + + if (!props.activeWalletInfo) { + return ( + + + + ); + } + + const isFetching = allTokensQuery.isFetching; + const noTokensFound = !isFetching && filteredTokens.length === 0; + + return ( + + {/* search */} + + + + + + + {/* tokens list */} + + {isFetching && + new Array(20).fill(0).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ok + + ))} + + {!isFetching && + filteredTokens.map((token) => ( + + props.setShowTokenInfo({ tokenAddress, chainId }) + } + isSelected={ + !!props.selectedToken && + props.selectedToken.tokenAddress.toLowerCase() === + token.token_address.toLowerCase() && + token.chain_id === props.selectedToken.chainId + } + /> + ))} + + {noTokensFound && ( +
+ + {debouncedSearch ? "No Tokens Found" : "No tokens in wallet"} + +
+ )} +
); } @@ -934,3 +1400,25 @@ const LeftContainer = /* @__PURE__ */ StyledDiv((_) => { position: "relative", }; }); + +function ScreenHeader(props: { + title: string; + description: string | undefined; +}) { + return ( + + + {props.title} + + {props.description && ( + <> + {" "} + + + {props.description} + + + )} + + ); +} 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 7fe62fd7c79..91e76f6bc0e 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 @@ -208,6 +208,8 @@ export function SwapUI(props: SwapUIProps) { {modalState.screen === "select-buy-token" && ( { setModalState((v) => ({ ...v, 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 3012a5916b8..5175c042b10 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 @@ -1,3 +1,4 @@ +import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; import type { Chain } from "../../../../../chains/types.js"; import type { Account, @@ -155,3 +156,7 @@ export type SwapPreparedQuote = Extract< BridgePrepareResult, { type: "buy" | "sell" } >; + +export type SelectedTab = + | { type: "chain"; chain: BridgeChain } + | { type: "your-tokens" }; 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 268001ed063..56f8b6dc515 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 @@ -77,13 +77,13 @@ export function useTokenBalances(options: { page: number; limit: number; walletAddress: string | undefined; - chainId: number | undefined; + chainId: number | number[] | undefined; }) { return useQuery({ queryKey: ["bridge/v1/wallets", options], - enabled: !!options.chainId && !!options.walletAddress, + enabled: !!options.walletAddress, queryFn: async () => { - if (!options.chainId || !options.walletAddress) { + if (!options.walletAddress) { throw new Error("invalid options"); } const baseUrl = getThirdwebBaseUrl("bridge"); @@ -91,7 +91,18 @@ export function useTokenBalances(options: { const url = new URL( `https://api.${isDev ? "thirdweb-dev" : "thirdweb"}.com/v1/wallets/${options.walletAddress}/tokens`, ); - url.searchParams.set("chainId", options.chainId.toString()); + + // Set chainId param(s) if provided + if (options.chainId !== undefined) { + if (Array.isArray(options.chainId)) { + for (const id of options.chainId) { + url.searchParams.append("chainId", id.toString()); + } + } else { + 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"); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/WalletDotIcon.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/WalletDotIcon.tsx index e424675e497..4578bf08c0a 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/WalletDotIcon.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/icons/WalletDotIcon.tsx @@ -1,9 +1,10 @@ -import type { IconFC } from "./types.js"; - /** * @internal */ -export const WalletDotIcon: IconFC = (props) => { +export const WalletDotIcon = (props: { + size?: string; + style?: React.CSSProperties; +}) => { return ( { viewBox="0 0 18 18" width={props.size} height={props.size} - style={{ color: props.color }} + style={props.style} role="presentation" > & { activeAccount: Account; activeWallet: Wallet; @@ -53,13 +53,15 @@ export function WalletManagerScreen( if (screen === "connect") { return ( - { setActive(w); props.onBack(); }} + shouldSetActive={false} + size="compact" /> ); } diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/WalletSwitcherConnectionScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/WalletSwitcherConnectionScreen.tsx index f5ef924aeb1..c6ec88f00f7 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/WalletSwitcherConnectionScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/WalletSwitcherConnectionScreen.tsx @@ -11,7 +11,7 @@ import type { ConnectLocale } from "../locale/types.js"; import { ConnectModalContent } from "../Modal/ConnectModalContent.js"; import { useSetupScreen } from "../Modal/screen.js"; -export type WalletSwitcherConnectionScreenProps = { +export type WalletConnectionScreenProps = { chain: Chain | undefined; chains: Chain[] | undefined; client: ThirdwebClient; @@ -24,17 +24,17 @@ export type WalletSwitcherConnectionScreenProps = { recommendedWallets: Wallet[] | undefined; showAllWallets: boolean; hiddenWallets?: WalletId[]; + size: "compact" | "wide"; walletConnect: | { projectId?: string; } | undefined; onBack: () => void; + shouldSetActive: boolean; }; -export function WalletSwitcherConnectionScreen( - props: WalletSwitcherConnectionScreenProps, -) { +export function WalletConnectionScreen(props: WalletConnectionScreenProps) { const walletChain = useActiveWalletChain(); const connectedWallets = useConnectedWallets(); const wallets = @@ -74,9 +74,9 @@ export function WalletSwitcherConnectionScreen( recommendedWallets={props.recommendedWallets} screenSetup={screenSetup} setModalVisibility={() => {}} - shouldSetActive={false} + shouldSetActive={props.shouldSetActive} showAllWallets={props.showAllWallets} - size="compact" + size={props.size} walletConnect={props.walletConnect} walletIdsToHide={connectedWallets.map((x) => x.id)} wallets={wallets} diff --git a/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx b/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx index a3b82b74f36..c9d33a12470 100644 --- a/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx +++ b/packages/thirdweb/src/react/web/ui/components/DynamicHeight.tsx @@ -5,10 +5,7 @@ import { useEffect, useRef, useState } from "react"; /** * @internal */ -export function DynamicHeight(props: { - children: React.ReactNode; - maxHeight?: string; -}) { +export function DynamicHeight(props: { children: React.ReactNode }) { const { height, elementRef } = useHeightObserver(); return ( @@ -20,14 +17,7 @@ export function DynamicHeight(props: { transition: "height 210ms ease", }} > -
- {props.children} -
+
{props.children}
); } diff --git a/packages/thirdweb/src/react/web/ui/components/Modal.tsx b/packages/thirdweb/src/react/web/ui/components/Modal.tsx index a94d91cd916..16ffd18172c 100644 --- a/packages/thirdweb/src/react/web/ui/components/Modal.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Modal.tsx @@ -99,6 +99,10 @@ export const Modal: React.FC<{ props.hide ? { height: 0, opacity: 0, overflow: "hidden", width: 0 } : { + maxHeight: + props.size === "compact" + ? compactModalMaxHeight + : undefined, height: props.size === "compact" ? "auto" : wideModalMaxHeight, maxWidth: @@ -125,9 +129,7 @@ export const Modal: React.FC<{ {props.title} {props.size === "compact" ? ( - - {props.children} - + {props.children} ) : ( props.children )} diff --git a/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx b/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx index d64e7ea3ac5..7ae120cb370 100644 --- a/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx +++ b/packages/thirdweb/src/stories/Bridge/Swap/SelectChain.stories.tsx @@ -1,11 +1,11 @@ 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 type { SelectedTab } from "../../../react/web/ui/Bridge/swap-widget/types.js"; import { storyClient } from "../../utils.js"; const meta = { @@ -17,9 +17,9 @@ const meta = { export default meta; export function WithDataDesktop() { - const [selectedChain, setSelectedChain] = useState( - undefined, - ); + const [selectedTab, setSelectedTab] = useState({ + type: "your-tokens", + }); return ( {}} - selectedChain={selectedChain} + selectedTab={selectedTab} /> ); } export function LoadingDesktop() { - const [selectedChain, setSelectedChain] = useState( - undefined, - ); + const [selectedTab, setSelectedTab] = useState({ + type: "your-tokens", + }); return ( {}} isPending={true} chains={[]} - selectedChain={selectedChain} + selectedTab={selectedTab} /> ); } export function WithDataMobile() { - const [selectedChain, setSelectedChain] = useState( - undefined, - ); + const [selectedTab, setSelectedTab] = useState({ + type: "your-tokens", + }); return ( {}} - selectedChain={selectedChain} + selectedTab={selectedTab} /> ); } export function LoadingMobile() { - const [selectedChain, setSelectedChain] = useState( - undefined, - ); + const [selectedTab, setSelectedTab] = useState({ + type: "your-tokens", + }); return ( {}} isPending={true} chains={[]} - selectedChain={selectedChain} + selectedTab={selectedTab} /> );