[MNY-319] SDK: Add token details screen in token selection UI (#8608)

<!--

## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes"

If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000):

## Notes for the reviewer

Anything important to call out? Be sure to also clarify these in your comments.

## How to test

Unit tests, playground, etc.

-->

<!-- start pr-codex -->

---

## PR-Codex overview
This PR focuses on enhancing the token selection UI by adding a token details screen and improving the display of token information within the `SwapWidget` and `BridgeWidget`.

### Detailed summary
- Added a token details screen in the token selection UI.
- Enhanced the `SelectedTokenButton` to include a more flexible display of the token symbol.
- Introduced `useTokenPrice` hook for fetching token prices.
- Updated `TokenButton` to include an info button for showing token details.
- Created `TokenInfoScreen` and `TokenInfoRow` components for displaying detailed token information.
- Integrated token info functionality into `TokenSelectionScreen`.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
This commit is contained in:
MananTank
2026-01-06 21:15:08 +00:00
parent 01004e8408
commit a3ff7572d6
8 changed files with 380 additions and 47 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---
Add token details screen in token selection UI in SwapWidget, BridgeWidget
@@ -213,6 +213,7 @@ export function FundWallet(props: FundWalletProps) {
>
<SelectToken
type="buy"
currency={props.currency}
selections={{
buyChainId: props.selectedToken?.chainId,
sellChainId: undefined,
@@ -128,7 +128,15 @@ export function SelectedTokenButton(props: {
{props.selectedToken?.isFetching ? (
<Skeleton width="60px" height={fontSize.md} />
) : (
<Text size="md" color="primaryText" weight={500}>
<Text
size="md"
color="primaryText"
weight={500}
style={{
whiteSpace: "nowrap",
maxWidth: "200px",
}}
>
{props.selectedToken?.data?.symbol}
</Text>
)}
@@ -138,8 +146,6 @@ export function SelectedTokenButton(props: {
size="xs"
color="secondaryText"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
@@ -1,9 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import type { ThirdwebClient } from "../../../../../client/client.js";
import { getToken } from "../../../../../pay/convert/get-token.js";
import type { Wallet } from "../../../../../wallets/interfaces/wallet.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 type { ActiveWalletInfo } from "./types.js";
import type { ActiveWalletInfo, TokenSelection } from "./types.js";
export function useActiveWalletInfo(
activeWalletOverride?: Wallet,
@@ -25,3 +28,26 @@ export function useActiveWalletInfo(
: undefined;
}, [activeAccount, activeWallet, activeChain, activeWalletOverride]);
}
export 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,
retry: false,
refetchOnWindowFocus: false,
});
}
@@ -1,7 +1,11 @@
import { PlusIcon } from "@radix-ui/react-icons";
import { useCallback, 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 { isNativeTokenAddress } from "../../../../../constants/addresses.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import { shortenAddress } from "../../../../../utils/address.js";
import { toTokens } from "../../../../../utils/units.js";
import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js";
import {
@@ -11,17 +15,26 @@ import {
spacing,
} from "../../../../core/design-system/index.js";
import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js";
import { InfoIcon } from "../../ConnectWallet/icons/InfoIcon.js";
import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js";
import { Container, noScrollBar } from "../../components/basic.js";
import { Button } from "../../components/buttons.js";
import { formatCurrencyAmount } from "../../ConnectWallet/screens/formatTokenBalance.js";
import {
Container,
Line,
ModalHeader,
noScrollBar,
} from "../../components/basic.js";
import { Button, IconButton } from "../../components/buttons.js";
import { CopyIcon } from "../../components/CopyIcon.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 { Link, Text } from "../../components/text.js";
import { StyledDiv } from "../../design-system/elements.js";
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 { SelectBridgeChain } from "./select-chain.js";
@@ -48,6 +61,7 @@ type SelectTokenUIProps = {
buyChainId: number | undefined;
sellChainId: number | undefined;
};
currency: SupportedFiatCurrency;
};
function findChain(chains: BridgeChain[], activeChainId: number | undefined) {
@@ -238,6 +252,7 @@ function SelectTokenUI(
props.onClose();
}}
isMobile={false}
key={props.selectedChain?.chainId}
selectedToken={props.selectedToken}
isFetching={props.isFetching}
ownedTokens={props.ownedTokens}
@@ -248,6 +263,7 @@ function SelectTokenUI(
client={props.client}
search={props.search}
setSearch={props.setSearch}
currency={props.currency}
/>
</Container>
</Container>
@@ -257,6 +273,7 @@ function SelectTokenUI(
if (screen === "select-token") {
return (
<TokenSelectionScreen
key={props.selectedChain?.chainId}
onSelectToken={(token) => {
props.setSelectedToken(token);
props.onClose();
@@ -272,6 +289,7 @@ function SelectTokenUI(
client={props.client}
search={props.search}
setSearch={props.setSearch}
currency={props.currency}
/>
);
}
@@ -320,6 +338,7 @@ function TokenButton(props: {
token: TokenBalance | Token;
client: ThirdwebClient;
onSelect: (tokenWithPrices: TokenSelection) => void;
onInfoClick: (tokenAddress: string, chainId: number) => void;
isSelected: boolean;
}) {
const theme = useCustomTheme();
@@ -332,6 +351,11 @@ function TokenButton(props: {
? props.token.price_data.price_usd * Number(tokenBalanceInUnits)
: undefined;
const tokenAddress =
"balance" in props.token ? props.token.token_address : props.token.address;
const chainId =
"balance" in props.token ? props.token.chain_id : props.token.chainId;
return (
<Button
variant={props.isSelected ? "secondary" : "ghost-solid"}
@@ -348,17 +372,10 @@ function TokenButton(props: {
}}
gap="sm"
onClick={async () => {
if ("balance" in props.token) {
props.onSelect({
tokenAddress: props.token.token_address,
chainId: props.token.chain_id,
});
} else {
props.onSelect({
tokenAddress: props.token.address,
chainId: props.token.chainId,
});
}
props.onSelect({
tokenAddress,
chainId,
});
}}
>
<Img
@@ -387,6 +404,7 @@ function TokenButton(props: {
</Container>
}
/>
<div
style={{
display: "flex",
@@ -395,6 +413,7 @@ function TokenButton(props: {
flex: 1,
}}
>
{/* left */}
<Container
flex="row"
style={{
@@ -402,7 +421,15 @@ function TokenButton(props: {
width: "100%",
}}
>
<Text size="md" color="primaryText" weight={500}>
<Text
size="md"
color="primaryText"
weight={500}
style={{
maxWidth: "200px",
whiteSpace: "nowrap",
}}
>
{props.token.symbol}
</Text>
@@ -416,6 +443,8 @@ function TokenButton(props: {
</Text>
)}
</Container>
{/* right */}
<Container
flex="row"
style={{
@@ -427,8 +456,6 @@ function TokenButton(props: {
size="xs"
color="secondaryText"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "200px",
}}
@@ -444,10 +471,237 @@ function TokenButton(props: {
)}
</Container>
</div>
<IconButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
props.onInfoClick(tokenAddress, chainId);
}}
style={{
padding: spacing.xxs,
borderRadius: radius.full,
}}
>
<InfoIcon size={iconSize.sm} />
</IconButton>
</Button>
);
}
function TokenInfoScreen(props: {
tokenAddress: string;
chainId: number;
client: ThirdwebClient;
onBack: () => void;
currency: SupportedFiatCurrency;
}) {
const theme = useCustomTheme();
const tokenQuery = useTokenPrice({
token: {
tokenAddress: props.tokenAddress,
chainId: props.chainId,
},
client: props.client,
});
const token = tokenQuery.data;
if (tokenQuery.isPending) {
return (
<Container
flex="column"
center="both"
expand
style={{ minHeight: "350px" }}
>
<Spinner size="lg" color="secondaryText" />
</Container>
);
}
if (!token) {
return (
<Container
flex="column"
center="both"
expand
style={{ minHeight: "350px" }}
>
<Text size="sm" color="secondaryText">
Token not found
</Text>
</Container>
);
}
const isNativeToken = isNativeTokenAddress(props.tokenAddress);
const explorerLink = isNativeToken
? `https://thirdweb.com/${props.chainId}`
: `https://thirdweb.com/${props.chainId}/${props.tokenAddress}`;
return (
<Container
flex="column"
expand
style={{ minHeight: "350px" }}
animate="fadein"
>
{/* Header */}
<Container px="md" py="md+">
<ModalHeader onBack={props.onBack} title="Token Details" />
</Container>
<Line dashed />
{/* Content */}
<Container flex="column" gap="md" px="md" py="lg">
{/* name + icon */}
<Container
flex="row"
style={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text size="sm" color="secondaryText">
Name
</Text>
<Container flex="row" gap="xxs" center="y">
<Img
src={token.iconUri || ""}
client={props.client}
width={iconSize.sm}
height={iconSize.sm}
style={{
borderRadius: radius.full,
}}
fallback={
<Container
style={{
background: `linear-gradient(45deg, white, ${theme.colors.accentText})`,
borderRadius: radius.full,
width: `${iconSize.sm}px`,
height: `${iconSize.sm}px`,
}}
/>
}
/>
<Text
size="sm"
color="primaryText"
weight={500}
style={{
maxWidth: "200px",
whiteSpace: "nowrap",
}}
>
{token.name}
</Text>
</Container>
</Container>
{/* symbol */}
<TokenInfoRow label="Symbol" value={token.symbol} />
{/* price */}
{"prices" in token && (
<TokenInfoRow
label="Price"
value={
token.prices[props.currency]
? formatCurrencyAmount(
props.currency,
token.prices[props.currency] as number,
)
: "N/A"
}
/>
)}
{/* market cap */}
{!!token.marketCapUsd && (
<TokenInfoRow
label="Market Cap"
value={formatCurrencyAmount(props.currency, token.marketCapUsd)}
/>
)}
{/* volume 24h */}
{!!token.volume24hUsd && (
<TokenInfoRow
label="Volume (24h)"
value={formatCurrencyAmount(props.currency, token.volume24hUsd)}
/>
)}
{/* address + link */}
<Container
flex="row"
style={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text size="sm" color="secondaryText">
Contract Address
</Text>
<Container flex="row" gap="3xs" center="y">
{!isNativeToken && (
<CopyIcon
text={props.tokenAddress}
iconSize={13}
tip="Copy Address"
/>
)}
<Link
href={explorerLink}
target="_blank"
rel="noreferrer"
color="accentText"
hoverColor="primaryText"
weight={500}
size="sm"
>
{isNativeToken
? "Native Currency"
: shortenAddress(props.tokenAddress)}
</Link>
</Container>
</Container>
</Container>
</Container>
);
}
function TokenInfoRow(props: { label: string; value: string }) {
return (
<Container
flex="row"
style={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text size="sm" color="secondaryText">
{props.label}
</Text>
<Text
size="sm"
color="primaryText"
weight={500}
style={{
maxWidth: "200px",
whiteSpace: "nowrap",
}}
>
{props.value}
</Text>
</Container>
);
}
function TokenSelectionScreen(props: {
selectedChain: BridgeChain | undefined;
isMobile: boolean;
@@ -461,12 +715,30 @@ function TokenSelectionScreen(props: {
showMore: (() => void) | undefined;
selectedToken: TokenSelection | undefined;
onSelectToken: (token: TokenSelection) => void;
currency: SupportedFiatCurrency;
}) {
const [tokenInfoScreen, setTokenInfoScreen] = useState<{
tokenAddress: string;
chainId: number;
} | null>(null);
const noTokensFound =
!props.isFetching &&
props.otherTokens.length === 0 &&
props.ownedTokens.length === 0;
if (tokenInfoScreen) {
return (
<TokenInfoScreen
tokenAddress={tokenInfoScreen.tokenAddress}
chainId={tokenInfoScreen.chainId}
client={props.client}
onBack={() => setTokenInfoScreen(null)}
currency={props.currency}
/>
);
}
return (
<Container fullHeight flex="column">
<Container px="md" pt="md+">
@@ -495,7 +767,7 @@ function TokenSelectionScreen(props: {
minHeight: "300px",
}}
>
<Spinner color="secondaryText" size="xl" />
<Spinner color="secondaryText" size="lg" />
</Container>
)}
@@ -574,6 +846,9 @@ function TokenSelectionScreen(props: {
token={token}
client={props.client}
onSelect={props.onSelectToken}
onInfoClick={(tokenAddress, chainId) =>
setTokenInfoScreen({ tokenAddress, chainId })
}
isSelected={
!!props.selectedToken &&
props.selectedToken.tokenAddress.toLowerCase() ===
@@ -615,6 +890,9 @@ function TokenSelectionScreen(props: {
token={token}
client={props.client}
onSelect={props.onSelectToken}
onInfoClick={(tokenAddress, chainId) =>
setTokenInfoScreen({ tokenAddress, chainId })
}
isSelected={
!!props.selectedToken &&
props.selectedToken.tokenAddress.toLowerCase() ===
@@ -626,12 +904,17 @@ function TokenSelectionScreen(props: {
{props.showMore && (
<Button
variant="secondary"
variant="outline"
fullWidth
style={{
borderRadius: radius.full,
}}
gap="xs"
onClick={() => {
props.showMore?.();
}}
>
<PlusIcon width={iconSize.sm} height={iconSize.sm} />
Load More
</Button>
)}
@@ -7,7 +7,6 @@ import type { prepare as SellPrepare } from "../../../../../bridge/Sell.js";
import type { TokenWithPrices } from "../../../../../bridge/types/Token.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 type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
import { getAddress } from "../../../../../utils/address.js";
import { toTokens, toUnits } from "../../../../../utils/units.js";
@@ -43,6 +42,7 @@ import { ActiveWalletDetails } from "../common/active-wallet-details.js";
import { DecimalInput } from "../common/decimal-input.js";
import { SelectedTokenButton } from "../common/selected-token-button.js";
import { useTokenBalance } from "../common/token-balance.js";
import { useTokenPrice } from "./hooks.js";
import { SelectToken } from "./select-token-ui.js";
import type {
ActiveWalletInfo,
@@ -82,29 +82,6 @@ type SwapUIProps = {
onDisconnect: (() => void) | undefined;
};
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,
retry: false,
refetchOnWindowFocus: false,
});
}
/**
* @internal
*/
@@ -244,6 +221,7 @@ export function SwapUI(props: SwapUIProps) {
}}
client={props.client}
selectedToken={props.buyToken}
currency={props.currency}
setSelectedToken={(token) => {
props.setBuyToken(token);
// if buy token is same as sell token, unset sell token
@@ -282,6 +260,7 @@ export function SwapUI(props: SwapUIProps) {
}}
client={props.client}
selectedToken={props.sellToken}
currency={props.currency}
setSelectedToken={(token) => {
props.setSellToken(token);
// if sell token is same as buy token, unset buy token
@@ -0,0 +1,23 @@
import type { IconFC } from "./types.js";
export const InfoIcon: IconFC = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="presentation"
width={props.size}
style={{ color: props.color }}
height={props.size}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
);
};
@@ -46,10 +46,20 @@ function formatMoney(
locale: string,
currencyCode: string,
): string {
if (value < 0) {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: currencyCode,
maximumFractionDigits: 6,
minimumFractionDigits: 0,
}).format(value);
}
return new Intl.NumberFormat(locale, {
style: "currency",
currency: currencyCode,
maximumFractionDigits: 2,
minimumFractionDigits: 0,
notation: "compact",
}).format(value);
}