diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index 65496c90206..880249c4261 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -221,6 +221,7 @@ export { export { AccountBalance, type AccountBalanceProps, + type AccountBalanceFormatParams, } from "../react/web/ui/prebuilt/Account/balance.js"; export { AccountName, diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx index 23f368e7df4..f494845ad5c 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx @@ -86,7 +86,10 @@ import { fadeInAnimation } from "../design-system/animations.js"; import { StyledButton } from "../design-system/elements.js"; import { AccountAddress } from "../prebuilt/Account/address.js"; import { AccountAvatar } from "../prebuilt/Account/avatar.js"; -import { AccountBalance } from "../prebuilt/Account/balance.js"; +import { + AccountBalance, + type AccountBalanceFormatParams, +} from "../prebuilt/Account/balance.js"; import { AccountBlobbie } from "../prebuilt/Account/blobbie.js"; import { AccountName } from "../prebuilt/Account/name.js"; import { AccountProvider } from "../prebuilt/Account/provider.js"; @@ -278,12 +281,13 @@ export const ConnectedWalletDetails: React.FC<{ chain={walletChain} loadingComponent={} fallbackComponent={} - formatFn={formatBalanceOnButton} + formatFn={formatAccountBalanceForButton} tokenAddress={ props.detailsButton?.displayBalanceToken?.[ Number(walletChain?.id) ] } + showFiatValue="USD" /> @@ -380,11 +384,12 @@ function DetailsModal(props: { } loadingComponent={} - formatFn={(num: number) => formatNumber(num, 9)} chain={walletChain} tokenAddress={ props.displayBalanceToken?.[Number(walletChain?.id)] } + formatFn={formatAccountBalanceForModal} + showFiatValue="USD" /> @@ -1006,8 +1011,70 @@ function DetailsModal(props: { ); } -function formatBalanceOnButton(num: number) { - return formatNumber(num, num < 1 ? 5 : 4); +/** + * Format the display balance for both crypto and fiat, in the Details button and Modal + * If both crypto balance and fiat balance exist, we have to keep the string very short to avoid UI issues. + * @internal + */ +function formatAccountBalanceForButton( + props: AccountBalanceFormatParams, +): string { + if (props.fiatBalance && props.fiatSymbol) { + // Need to keep them short to avoid UI overflow issues + const formattedTokenBalance = formatNumber(props.tokenBalance, 1); + const formattedFiatBalance = formatFiatValue(props.fiatBalance, 0); + return `${formattedTokenBalance} ${props.tokenSymbol} (${props.fiatSymbol}${formattedFiatBalance})`; + } + const formattedTokenBalance = formatNumber( + props.tokenBalance, + props.tokenBalance < 1 ? 5 : 4, + ); + return `${formattedTokenBalance} ${props.tokenSymbol}`; +} + +function formatAccountBalanceForModal( + props: AccountBalanceFormatParams, +): string { + if (props.fiatBalance && props.fiatSymbol) { + // Need to keep them short to avoid UI overflow issues + const formattedTokenBalance = formatNumber(props.tokenBalance, 5); + const formattedFiatBalance = formatFiatValue(props.fiatBalance, 4); + return `${formattedTokenBalance} ${props.tokenSymbol} (${props.fiatSymbol}${formattedFiatBalance})`; + } + const formattedTokenBalance = formatNumber(props.tokenBalance, 9); + return `${formattedTokenBalance} ${props.tokenSymbol}`; +} + +function formatFiatValue(value: number, decimals: number): string { + const num = formatNumber(value, decimals); + if (num < 1000) { + return num.toString(); + } + if (num < 1_000_000) { + return formatLargeNumber(num, 1000000, "k"); + } + if (num < 1000000000) { + return formatLargeNumber(num, 1000000, "M"); + } + return formatLargeNumber(num, 1000000000, "B"); +} + +/** + * Shorten the string for large value + * 10_000 -> 10k + * 1_000_000 -> 1M + * 1_000_000_000 -> 1B + */ +function formatLargeNumber( + value: number, + divisor: number, + suffix: "k" | "M" | "B", +) { + const quotient = value / divisor; + if (Number.isInteger(quotient)) { + return Math.floor(quotient) + suffix; + } + return quotient.toFixed(1).replace(/\.0$/, "") + suffix; } const WalletInfoButton = /* @__PURE__ */ StyledButton((_) => { diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.test.tsx index 3a4df585f20..252da834f24 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.test.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.test.tsx @@ -1,37 +1,11 @@ import { describe, expect, it } from "vitest"; -import { ANVIL_CHAIN } from "~test/chains.js"; import { render, screen, waitFor } from "~test/react-render.js"; import { TEST_CLIENT } from "~test/test-clients.js"; import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; -import { getWalletBalance } from "../../../../../wallets/utils/getWalletBalance.js"; import { AccountBalance } from "./balance.js"; import { AccountProvider } from "./provider.js"; describe.runIf(process.env.TW_SECRET_KEY)("AccountBalance component", () => { - it("format the balance properly", async () => { - const roundTo1Decimal = (num: number): number => Math.round(num * 10) / 10; - const balance = await getWalletBalance({ - chain: ANVIL_CHAIN, - client: TEST_CLIENT, - address: TEST_ACCOUNT_A.address, - }); - - render( - - - , - ); - - waitFor(() => - expect( - screen.getByText(roundTo1Decimal(Number(balance.displayValue)), { - exact: true, - selector: "span", - }), - ).toBeInTheDocument(), - ); - }); - it("should fallback properly if failed to load", () => { render( diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx index dafa851123f..cfd7431cf39 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx @@ -4,13 +4,22 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type React from "react"; import type { JSX } from "react"; import type { Chain } from "../../../../../chains/types.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { convertCryptoToFiat } from "../../../../../exports/pay.js"; import { useActiveWalletChain } from "../../../../../react/core/hooks/wallets/useActiveWalletChain.js"; -import { - type GetWalletBalanceResult, - getWalletBalance, -} from "../../../../../wallets/utils/getWalletBalance.js"; +import { getWalletBalance } from "../../../../../wallets/utils/getWalletBalance.js"; import { useAccountContext } from "./provider.js"; +/** + * @internal + */ +export type AccountBalanceFormatParams = { + tokenBalance: number; + tokenSymbol: string; + fiatBalance?: number; + fiatSymbol?: string; +}; + /** * Props for the AccountBalance component * @component @@ -33,7 +42,7 @@ export interface AccountBalanceProps * use this function to transform the balance display value like round up the number * Particularly useful to avoid overflowing-UI issues */ - formatFn?: (num: number) => number; + formatFn?: (props: AccountBalanceFormatParams) => string; /** * This component will be shown while the balance of the account is being fetched * If not passed, the component will return `null`. @@ -67,9 +76,11 @@ export interface AccountBalanceProps * Optional `useQuery` params */ queryOptions?: Omit< - UseQueryOptions, + UseQueryOptions, "queryFn" | "queryKey" >; + + showFiatValue?: "USD"; } /** @@ -149,10 +160,11 @@ export interface AccountBalanceProps export function AccountBalance({ chain, tokenAddress, - formatFn, loadingComponent, fallbackComponent, queryOptions, + formatFn, + showFiatValue, ...restProps }: AccountBalanceProps) { const { address, client } = useAccountContext(); @@ -164,20 +176,61 @@ export function AccountBalance({ chainToLoad?.id || -1, address || "0x0", { tokenAddress }, + showFiatValue, ] as const, - queryFn: async () => { + queryFn: async (): Promise => { if (!chainToLoad) { throw new Error("chain is required"); } if (!client) { throw new Error("client is required"); } - return getWalletBalance({ + const tokenBalanceData = await getWalletBalance({ chain: chainToLoad, client, address, tokenAddress, }); + + if (!tokenBalanceData) { + throw new Error( + `Failed to retrieve ${tokenAddress ? `token: ${tokenAddress}` : "native token"} balance for address: ${address} on chainId:${chainToLoad.id}`, + ); + } + + if (showFiatValue) { + const fiatData = await convertCryptoToFiat({ + fromAmount: Number(tokenBalanceData.displayValue), + fromTokenAddress: tokenAddress || NATIVE_TOKEN_ADDRESS, + to: showFiatValue, + chain: chainToLoad, + client, + }).catch(() => undefined); + + // We can never support 100% of token out there, so if something fails to resolve, it's expected + // in that case just return the tokenBalance and symbol + return { + tokenBalance: Number(tokenBalanceData.displayValue), + tokenSymbol: tokenBalanceData.symbol, + fiatBalance: fiatData?.result, + fiatSymbol: fiatData?.result + ? new Intl.NumberFormat("en", { + style: "currency", + currency: showFiatValue, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) + .formatToParts(0) + .find((p) => p.type === "currency")?.value || + showFiatValue.toUpperCase() + : undefined, + }; + } + + return { + tokenBalance: Number(tokenBalanceData.displayValue), + tokenSymbol: tokenBalanceData.symbol, + }; }, ...queryOptions, }); @@ -190,13 +243,24 @@ export function AccountBalance({ return fallbackComponent || null; } - const displayValue = formatFn - ? formatFn(Number(balanceQuery.data.displayValue)) - : balanceQuery.data.displayValue; + if (formatFn) { + return {formatFn(balanceQuery.data)}; + } + + const { tokenBalance, tokenSymbol, fiatBalance, fiatSymbol } = + balanceQuery.data; + + if (fiatBalance && fiatSymbol) { + return ( + + {`${tokenBalance} ${tokenSymbol} (${fiatSymbol}${fiatBalance})`} + + ); + } return ( - {displayValue} {balanceQuery.data.symbol} + {tokenBalance} {tokenSymbol} ); }