[SDK] Feature: Account components (#5388)
cnct-2137
<!-- start pr-codex -->
---
## PR-Codex overview
This PR introduces a new `Account` component suite to the `thirdweb` library, enhancing user account management with features like account name, address, avatar, and balance display. It also marks existing `NFT` components as `@beta`.
### Detailed summary
- Added `Account` components: `AccountName`, `AccountAddress`, `AccountAvatar`, `AccountBalance`, and `AccountBlobbie`.
- Updated existing `NFT` components to `@beta`.
- Introduced tests for new `Account` components.
- Enhanced `AccountProvider` for context management.
- Updated documentation for new components and props.
> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`
<!-- end pr-codex -->
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"thirdweb": minor
|
||||
---
|
||||
|
||||
Add headless UI component: Account (Name, Image, Address, Balance)
|
||||
@@ -109,6 +109,22 @@ export const sidebar: SideBar = {
|
||||
href: `${slug}/${name}`,
|
||||
icon: <CodeIcon />,
|
||||
})),
|
||||
{
|
||||
name: "Account",
|
||||
isCollapsible: true,
|
||||
links: [
|
||||
"AccountProvider",
|
||||
"AccountAddress",
|
||||
"AccountAvatar",
|
||||
"AccountName",
|
||||
"AccountBlobbie",
|
||||
"AccountBalance",
|
||||
].map((name) => ({
|
||||
name,
|
||||
href: `${slug}/${name}`,
|
||||
icon: <CodeIcon />,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -41,6 +41,7 @@ const tagsToGroup = {
|
||||
"@social": "Social API",
|
||||
"@modules": "Modules",
|
||||
"@client": "Client",
|
||||
"@account": "Account Components",
|
||||
} as const;
|
||||
|
||||
type TagKey = keyof typeof tagsToGroup;
|
||||
@@ -79,6 +80,7 @@ const sidebarGroupOrder: TagKey[] = [
|
||||
"@theme",
|
||||
"@utils",
|
||||
"@others",
|
||||
"@account",
|
||||
];
|
||||
|
||||
function findTag(
|
||||
|
||||
@@ -212,3 +212,26 @@ export type {
|
||||
// Site Embed and Linking
|
||||
export { SiteEmbed } from "../react/web/ui/SiteEmbed.js";
|
||||
export { SiteLink } from "../react/web/ui/SiteLink.js";
|
||||
|
||||
// Account
|
||||
export {
|
||||
AccountAddress,
|
||||
type AccountAddressProps,
|
||||
} from "../react/web/ui/prebuilt/Account/address.js";
|
||||
export {
|
||||
AccountBalance,
|
||||
type AccountBalanceProps,
|
||||
} from "../react/web/ui/prebuilt/Account/balance.js";
|
||||
export {
|
||||
AccountName,
|
||||
type AccountNameProps,
|
||||
} from "../react/web/ui/prebuilt/Account/name.js";
|
||||
export { AccountBlobbie } from "../react/web/ui/prebuilt/Account/blobbie.js";
|
||||
export {
|
||||
AccountProvider,
|
||||
type AccountProviderProps,
|
||||
} from "../react/web/ui/prebuilt/Account/provider.js";
|
||||
export {
|
||||
AccountAvatar,
|
||||
type AccountAvatarProps,
|
||||
} from "../react/web/ui/prebuilt/Account/avatar.js";
|
||||
|
||||
@@ -21,18 +21,34 @@ const COLOR_OPTIONS = [
|
||||
["#fda4af", "#be123c"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Props for the Blobbie component
|
||||
* @component
|
||||
*/
|
||||
export type BlobbieProps = {
|
||||
address: Address;
|
||||
style?: Omit<React.CSSProperties, "backgroundImage">;
|
||||
className?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A unique gradient avatar based on the provided address.
|
||||
* @param props The component props.
|
||||
* @param props.address The address to generate the gradient with.
|
||||
* @param props.size The size of each side of the square avatar (in pixels)
|
||||
* @param props.style The CSS style for the component - excluding `backgroundImage`
|
||||
* @param props.className The className for the component
|
||||
* @param props.size The size of each side of the square avatar (in pixels). This prop will override the `width` and `height` attributes from the `style` prop.
|
||||
* @component
|
||||
* @wallet
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Blobbie address="0x...." size={24} />
|
||||
* import { Blobbie } from "thirdweb/react";
|
||||
*
|
||||
* <Blobbie address="0x...." className="w-10 h-10" />
|
||||
* ```
|
||||
* @wallet
|
||||
*/
|
||||
export function Blobbie(props: { address: Address; size: number }) {
|
||||
export function Blobbie(props: BlobbieProps) {
|
||||
const id = useId();
|
||||
const colors = useMemo(
|
||||
() =>
|
||||
@@ -46,10 +62,16 @@ export function Blobbie(props: { address: Address; size: number }) {
|
||||
<div
|
||||
id={id}
|
||||
style={{
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
...props.style,
|
||||
backgroundImage: `radial-gradient(ellipse at left bottom, ${colors[0]}, ${colors[1]})`,
|
||||
...(props.size
|
||||
? {
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}
|
||||
: undefined),
|
||||
}}
|
||||
className={props.className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, waitFor } from "~test/react-render.js";
|
||||
import { TEST_CLIENT } from "~test/test-clients.js";
|
||||
import { shortenAddress } from "../../../../../utils/address.js";
|
||||
import { AccountAddress } from "./address.js";
|
||||
import { AccountProvider } from "./provider.js";
|
||||
|
||||
describe.runIf(process.env.TW_SECRET_KEY)("AccountAddress component", () => {
|
||||
it("should format the address properly", () => {
|
||||
render(
|
||||
<AccountProvider
|
||||
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
|
||||
client={TEST_CLIENT}
|
||||
>
|
||||
<AccountAddress formatFn={shortenAddress} />
|
||||
</AccountProvider>,
|
||||
);
|
||||
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("0x1234...1DF3", {
|
||||
exact: true,
|
||||
selector: "span",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useAccountContext } from "./provider.js";
|
||||
|
||||
/**
|
||||
* @component
|
||||
* @account
|
||||
*/
|
||||
export interface AccountAddressProps
|
||||
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
|
||||
/**
|
||||
* The function used to transform (format) the wallet address
|
||||
* Specifically useful for shortening the wallet.
|
||||
*
|
||||
* This function should take in a string and output a string
|
||||
*/
|
||||
formatFn?: (str: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns a <span> containing the full wallet address of the account
|
||||
*
|
||||
* @example
|
||||
* ### Basic usage
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountAddress } from "thirdweb/react";
|
||||
*
|
||||
* <AccountProvider address="0x12345674b599ce99958242b3D3741e7b01841DF3" client={TW_CLIENT}>
|
||||
* <AccountAddress />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
* Result:
|
||||
* ```html
|
||||
* <span>0x12345674b599ce99958242b3D3741e7b01841DF3</span>
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* ### Shorten the address
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountAddress } from "thirdweb/react";
|
||||
* import { shortenAddress } from "thirdweb/utils";
|
||||
*
|
||||
* <AccountProvider address="0x12345674b599ce99958242b3D3741e7b01841DF3" client={TW_CLIENT}>
|
||||
* <AccountAddress formatFn={shortenAddress} />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
* Result:
|
||||
* ```html
|
||||
* <span>0x1234...1DF3</span>
|
||||
* ```
|
||||
*
|
||||
* @component
|
||||
* @account
|
||||
* @beta
|
||||
*/
|
||||
export function AccountAddress({
|
||||
formatFn,
|
||||
...restProps
|
||||
}: AccountAddressProps) {
|
||||
const { address } = useAccountContext();
|
||||
const value = formatFn ? formatFn(address) : address;
|
||||
return <span {...restProps}>{value}</span>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 { AccountAvatar } from "./avatar.js";
|
||||
import { AccountProvider } from "./provider.js";
|
||||
|
||||
describe.runIf(process.env.TW_SECRET_KEY)("AccountAvatar component", () => {
|
||||
it("should render an image", () => {
|
||||
render(
|
||||
<AccountProvider
|
||||
address={"0x12345674b599ce99958242b3D3741e7b01841DF3"}
|
||||
client={TEST_CLIENT}
|
||||
>
|
||||
<AccountAvatar />
|
||||
</AccountProvider>,
|
||||
);
|
||||
|
||||
waitFor(() => expect(screen.getByRole("img")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("should fallback properly if failed to load", () => {
|
||||
render(
|
||||
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
|
||||
<AccountAvatar fallbackComponent={<span>oops</span>} />
|
||||
</AccountProvider>,
|
||||
);
|
||||
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("oops", {
|
||||
exact: true,
|
||||
selector: "span",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT render anything if fail to resolve avatar", () => {
|
||||
render(
|
||||
<AccountProvider address={"invalid-wallet-address"} client={TEST_CLIENT}>
|
||||
<AccountAvatar />
|
||||
</AccountProvider>,
|
||||
);
|
||||
|
||||
waitFor(() => expect(screen.getByRole("img")).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import type { JSX } from "react";
|
||||
import { resolveAvatar } from "../../../../../extensions/ens/resolve-avatar.js";
|
||||
import {
|
||||
type ResolveNameOptions,
|
||||
resolveName,
|
||||
} from "../../../../../extensions/ens/resolve-name.js";
|
||||
import { getSocialProfiles } from "../../../../../social/profiles.js";
|
||||
import type { SocialProfile } from "../../../../../social/types.js";
|
||||
import { parseAvatarRecord } from "../../../../../utils/ens/avatar.js";
|
||||
import { useAccountContext } from "./provider.js";
|
||||
|
||||
/**
|
||||
* Props for the AccountAvatar component
|
||||
* @component
|
||||
* @account
|
||||
*/
|
||||
export interface AccountAvatarProps
|
||||
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src">,
|
||||
Omit<ResolveNameOptions, "client" | "address"> {
|
||||
/**
|
||||
* Use this prop to prioritize the social profile that you want to display
|
||||
* This is useful for a wallet containing multiple social profiles.
|
||||
* This component inherits all attributes of a HTML's <img />, so you can interact with it just like a normal <img />
|
||||
*
|
||||
* @example
|
||||
* If you have ENS, Lens and Farcaster profiles linked to your wallet
|
||||
* you can prioritize showing the image for Lens by:
|
||||
* ```tsx
|
||||
* <AccountAvatar
|
||||
* socialType="lens" // Choose between: "farcaster" | "lens" | "ens"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
socialType?: SocialProfile["type"];
|
||||
|
||||
/**
|
||||
* This component will be shown while the avatar of the account is being fetched
|
||||
* If not passed, the component will return `null`.
|
||||
*
|
||||
* You can pass a loading sign or spinner to this prop.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AccountAvatar loadingComponent={<Spinner />} />
|
||||
* ```
|
||||
*/
|
||||
loadingComponent?: JSX.Element;
|
||||
/**
|
||||
* This component will be shown if the request for fetching the avatar is done
|
||||
* but could not retreive any result.
|
||||
* You can pass a dummy avatar/image to this prop.
|
||||
*
|
||||
* If not passed, the component will return `null`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AccountAvatar fallbackComponent={<DummyImage />} />
|
||||
* ```
|
||||
*/
|
||||
fallbackComponent?: JSX.Element;
|
||||
|
||||
/**
|
||||
* Optional query options for `useQuery`
|
||||
*/
|
||||
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
|
||||
}
|
||||
|
||||
/**
|
||||
* The component for showing the avatar of the account.
|
||||
* If fetches all the social profiles linked to your wallet, including: Farcaster, ENS, Lens (more to be added)
|
||||
* You can choose which social profile you want to display. Defaults to the first item in the list.
|
||||
*
|
||||
* @example
|
||||
* ### Basic usage
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
|
||||
*
|
||||
* <AccountProvider address="0x...">
|
||||
* <AccountAvatar />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
* Result: An <img /> component, if the avatar is resolved successfully
|
||||
* ```html
|
||||
* <img alt="" src="resolved-url-for-the-avatar" />
|
||||
* ```
|
||||
*
|
||||
* ### Show a loading sign when the avatar is being resolved
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
|
||||
*
|
||||
* <AccountProvider address="0x...">
|
||||
* <AccountAvatar
|
||||
* loadingComponent={<YourLoadingComponent />}
|
||||
* />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
*
|
||||
* ### Fallback to something when the avatar fails to resolve
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
|
||||
*
|
||||
* <AccountProvider address="0x...">
|
||||
* <AccountAvatar
|
||||
* fallbackComponent={<DummyImage />}
|
||||
* />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
*
|
||||
* ### Select a social profile to display
|
||||
* If you wallet associates with more than one social profiles (Lens, Farcaster, ENS, etc.)
|
||||
* You can specify which service you want to prioritize using the `socialType` props
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
|
||||
*
|
||||
* <AccountProvider address="0x...">
|
||||
* <AccountAvatar
|
||||
* // Choose between: "farcaster" | "lens" | "ens"
|
||||
* socialType={"ens"}
|
||||
* />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
*
|
||||
* ### Custom ENS resolver chain
|
||||
* This component shares the same props with the ENS extension `resolveAvatar`
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountAvatar } from "thirdweb/react";
|
||||
* import { base } from "thirdweb/chains";
|
||||
*
|
||||
* <AccountProvider address="0x...">
|
||||
* <AccountAvatar
|
||||
* resolverAddress={"0x..."}
|
||||
* resolverChain={base}
|
||||
* />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
*
|
||||
* ### Custom query options for useQuery
|
||||
* This component uses `@tanstack-query`'s useQuery internally.
|
||||
* You can use the `queryOptions` prop for more fine-grained control
|
||||
* ```tsx
|
||||
* <AccountAvatar
|
||||
* queryOptions={{
|
||||
* enabled: isEnabled,
|
||||
* retry: 3,
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
* @returns An <img /> if the avatar is resolved successfully
|
||||
* @component
|
||||
* @account
|
||||
* @beta
|
||||
*/
|
||||
export function AccountAvatar({
|
||||
socialType,
|
||||
resolverAddress,
|
||||
resolverChain,
|
||||
loadingComponent,
|
||||
fallbackComponent,
|
||||
queryOptions,
|
||||
...restProps
|
||||
}: AccountAvatarProps) {
|
||||
const { address, client } = useAccountContext();
|
||||
const avatarQuery = useQuery({
|
||||
queryKey: ["account-avatar", address],
|
||||
queryFn: async (): Promise<string> => {
|
||||
const [socialData, ensName] = await Promise.all([
|
||||
getSocialProfiles({ address, client }),
|
||||
resolveName({
|
||||
client,
|
||||
address: address || "",
|
||||
resolverAddress,
|
||||
resolverChain,
|
||||
}),
|
||||
]);
|
||||
|
||||
const uri = socialData?.filter(
|
||||
(p) => p.avatar && (socialType ? p.type === socialType : true),
|
||||
)[0]?.avatar;
|
||||
|
||||
const [resolvedSocialAvatar, resolvedENSAvatar] = await Promise.all([
|
||||
uri ? parseAvatarRecord({ client, uri }) : undefined,
|
||||
ensName
|
||||
? resolveAvatar({
|
||||
client,
|
||||
name: ensName,
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
// If no social image + ens name found -> exit and show <Blobbie />
|
||||
if (!resolvedSocialAvatar && !resolvedENSAvatar) {
|
||||
throw new Error("Failed to resolve social + ens avatar");
|
||||
}
|
||||
|
||||
// else, prioritize the social image first
|
||||
if (resolvedSocialAvatar) {
|
||||
return resolvedSocialAvatar;
|
||||
}
|
||||
|
||||
if (resolvedENSAvatar) {
|
||||
return resolvedENSAvatar;
|
||||
}
|
||||
|
||||
throw new Error("Failed to resolve social + ens avatar");
|
||||
},
|
||||
...queryOptions,
|
||||
});
|
||||
|
||||
if (avatarQuery.isLoading) {
|
||||
return loadingComponent || null;
|
||||
}
|
||||
|
||||
if (!avatarQuery.data) {
|
||||
return fallbackComponent || null;
|
||||
}
|
||||
|
||||
return <img src={avatarQuery.data} {...restProps} alt={restProps.alt} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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(
|
||||
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
|
||||
<AccountBalance chain={ANVIL_CHAIN} formatFn={roundTo1Decimal} />
|
||||
</AccountProvider>,
|
||||
);
|
||||
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(roundTo1Decimal(Number(balance.displayValue)), {
|
||||
exact: true,
|
||||
selector: "span",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fallback properly if failed to load", () => {
|
||||
render(
|
||||
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
|
||||
<AccountBalance
|
||||
chain={undefined}
|
||||
fallbackComponent={<span>oops</span>}
|
||||
/>
|
||||
</AccountProvider>,
|
||||
);
|
||||
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("oops", {
|
||||
exact: true,
|
||||
selector: "span",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
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 { useActiveWalletChain } from "../../../../../react/core/hooks/wallets/useActiveWalletChain.js";
|
||||
import {
|
||||
type GetWalletBalanceResult,
|
||||
getWalletBalance,
|
||||
} from "../../../../../wallets/utils/getWalletBalance.js";
|
||||
import { useAccountContext } from "./provider.js";
|
||||
|
||||
/**
|
||||
* Props for the AccountBalance component
|
||||
* @component
|
||||
* @account
|
||||
*/
|
||||
export interface AccountBalanceProps
|
||||
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
|
||||
/**
|
||||
* The network to fetch balance on
|
||||
* If not passed, the component will use the current chain that the wallet is connected to (`useActiveWalletChain()`)
|
||||
*/
|
||||
chain?: Chain;
|
||||
/**
|
||||
* By default this component will fetch the balance for the native token on a given chain
|
||||
* If you want to fetch balance for an ERC20 token, use the `tokenAddress` props
|
||||
*/
|
||||
tokenAddress?: string;
|
||||
/**
|
||||
* A function to format the balance's display value
|
||||
* 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;
|
||||
/**
|
||||
* This component will be shown while the balance of the account is being fetched
|
||||
* If not passed, the component will return `null`.
|
||||
*
|
||||
* You can/should pass a loading sign or spinner to this prop.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AccountBalance
|
||||
* chain={ethereum}
|
||||
* loadingComponent={<Spinner />}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
loadingComponent?: JSX.Element;
|
||||
/**
|
||||
* This component will be shown if the balance fails to be retreived
|
||||
* If not passed, the component will return `null`.
|
||||
*
|
||||
* You can/should pass a descriptive text/component to this prop, indicating that the
|
||||
* balance was not fetched succesfully
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AccountBalance
|
||||
* chain={nonExistentChain}
|
||||
* fallbackComponent={"Failed to load"}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
fallbackComponent?: JSX.Element;
|
||||
/**
|
||||
* Optional `useQuery` params
|
||||
*/
|
||||
queryOptions?: Omit<
|
||||
UseQueryOptions<GetWalletBalanceResult>,
|
||||
"queryFn" | "queryKey"
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component fetches and shows the balance of the wallet address on a given chain.
|
||||
* It inherits all the attributes of a HTML <span> component, hence you can style it just like how you would style a normal <span>
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ### Basic usage
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountBalance } from "thirdweb/react";
|
||||
* import { ethereum } from "thirdweb/chains";
|
||||
*
|
||||
* <AccountProvider address="0x...">
|
||||
* <AccountBalance chain={ethereum} />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
* Result:
|
||||
* ```html
|
||||
* <span>1.091435 ETH</span>
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* ### Format the balance (round up, shorten etc.)
|
||||
* The AccountBalance component accepts a `formatFn` which takes in a number and outputs a number
|
||||
* The function is used to modify the display value of the wallet balance
|
||||
*
|
||||
* ```tsx
|
||||
* const roundTo1Decimal = (num: number):number => Math.round(num * 10) / 10;
|
||||
*
|
||||
* <AccountBalance formatFn={roundTo1Decimal} />
|
||||
* ```
|
||||
*
|
||||
* Result:
|
||||
* ```html
|
||||
* <span>1.1 ETH</span>
|
||||
* ```
|
||||
*
|
||||
* ### Show a loading sign when the balance is being fetched
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountBalance } from "thirdweb/react";
|
||||
*
|
||||
* <AccountProvider address="0x...">
|
||||
* <AccountBalance
|
||||
* chain={ethereum}
|
||||
* loadingComponent={<Spinner />}
|
||||
* />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
*
|
||||
* ### Fallback to something when the balance fails to resolve
|
||||
* ```tsx
|
||||
* <AccountProvider address="0x...">
|
||||
* <AccountBalance
|
||||
* chain={nonExistentChain}
|
||||
* fallbackComponent={"Failed to load"}
|
||||
* />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
*
|
||||
* ### Custom query options for useQuery
|
||||
* This component uses `@tanstack-query`'s useQuery internally.
|
||||
* You can use the `queryOptions` prop for more fine-grained control
|
||||
* ```tsx
|
||||
* <AccountBalance
|
||||
* queryOptions={{
|
||||
* enabled: isEnabled,
|
||||
* retry: 4,
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @component
|
||||
* @account
|
||||
* @beta
|
||||
*/
|
||||
export function AccountBalance({
|
||||
chain,
|
||||
tokenAddress,
|
||||
formatFn,
|
||||
loadingComponent,
|
||||
fallbackComponent,
|
||||
queryOptions,
|
||||
...restProps
|
||||
}: AccountBalanceProps) {
|
||||
const { address, client } = useAccountContext();
|
||||
const walletChain = useActiveWalletChain();
|
||||
const chainToLoad = chain || walletChain;
|
||||
const balanceQuery = useQuery({
|
||||
queryKey: [
|
||||
"walletBalance",
|
||||
chainToLoad?.id || -1,
|
||||
address || "0x0",
|
||||
{ tokenAddress },
|
||||
] as const,
|
||||
queryFn: async () => {
|
||||
if (!chainToLoad) {
|
||||
throw new Error("chain is required");
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error("client is required");
|
||||
}
|
||||
return getWalletBalance({
|
||||
chain: chainToLoad,
|
||||
client,
|
||||
address,
|
||||
tokenAddress,
|
||||
});
|
||||
},
|
||||
...queryOptions,
|
||||
});
|
||||
|
||||
if (balanceQuery.isLoading) {
|
||||
return loadingComponent || null;
|
||||
}
|
||||
|
||||
if (!balanceQuery.data) {
|
||||
return fallbackComponent || null;
|
||||
}
|
||||
|
||||
const displayValue = formatFn
|
||||
? formatFn(Number(balanceQuery.data.displayValue))
|
||||
: balanceQuery.data.displayValue;
|
||||
|
||||
return (
|
||||
<span {...restProps}>
|
||||
{displayValue} {balanceQuery.data.symbol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Blobbie, type BlobbieProps } from "../../ConnectWallet/Blobbie.js";
|
||||
import { useAccountContext } from "./provider.js";
|
||||
|
||||
/**
|
||||
* A wrapper for the Blobbie component
|
||||
* @param props BlobbieProps
|
||||
* @beta
|
||||
*/
|
||||
export function AccountBlobbie(props: Omit<BlobbieProps, "address">) {
|
||||
const { address } = useAccountContext();
|
||||
return <Blobbie {...props} address={address} />;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen, waitFor } from "~test/react-render.js";
|
||||
import { TEST_CLIENT } from "~test/test-clients.js";
|
||||
import { AccountName } from "./name.js";
|
||||
import { AccountProvider } from "./provider.js";
|
||||
|
||||
describe.runIf(process.env.TW_SECRET_KEY)("AccountName component", () => {
|
||||
it("should return the correct social name", () => {
|
||||
render(
|
||||
<AccountProvider
|
||||
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
|
||||
client={TEST_CLIENT}
|
||||
>
|
||||
<AccountName />
|
||||
</AccountProvider>,
|
||||
);
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("kien-ngo", {
|
||||
exact: true,
|
||||
selector: "span",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return the correct FORMATTED social name", () => {
|
||||
render(
|
||||
<AccountProvider
|
||||
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
|
||||
client={TEST_CLIENT}
|
||||
>
|
||||
<AccountName formatFn={(str: string) => `${str}-formatted`} />
|
||||
</AccountProvider>,
|
||||
);
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("kien-ngo-formatted", {
|
||||
exact: true,
|
||||
selector: "span",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fallback properly when fail to resolve social name", () => {
|
||||
render(
|
||||
<AccountProvider address="invalid-wallet-address" client={TEST_CLIENT}>
|
||||
<AccountName fallbackComponent={<span>oops</span>} />
|
||||
</AccountProvider>,
|
||||
);
|
||||
|
||||
waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("oops", {
|
||||
exact: true,
|
||||
selector: "span",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import type { JSX } from "react";
|
||||
import {
|
||||
type ResolveNameOptions,
|
||||
resolveName,
|
||||
} from "../../../../../extensions/ens/resolve-name.js";
|
||||
import { getSocialProfiles } from "../../../../../social/profiles.js";
|
||||
import type { SocialProfile } from "../../../../../social/types.js";
|
||||
import { useAccountContext } from "./provider.js";
|
||||
|
||||
/**
|
||||
* Props for the AccountName component
|
||||
* @component
|
||||
* @account
|
||||
*/
|
||||
export interface AccountNameProps
|
||||
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children">,
|
||||
Omit<ResolveNameOptions, "client" | "address"> {
|
||||
/**
|
||||
* A function used to transform (format) the name of the account.
|
||||
* it should take in a string and output a string.
|
||||
*
|
||||
* This function is particularly useful
|
||||
*/
|
||||
formatFn?: (str: string) => string;
|
||||
/**
|
||||
* Use this prop to prioritize the social profile that you want to display
|
||||
* This is useful for a wallet containing multiple social profiles
|
||||
*/
|
||||
socialType?: SocialProfile["type"];
|
||||
/**
|
||||
* This component will be shown while the name of the account is being fetched
|
||||
* If not passed, the component will return `null`.
|
||||
*
|
||||
* You can pass a loading sign or spinner to this prop.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AccountName loadingComponent={<Spinner />} />
|
||||
* ```
|
||||
*/
|
||||
loadingComponent?: JSX.Element;
|
||||
/**
|
||||
* This component will be shown if the request for fetching the name is done but could not retreive any result.
|
||||
* You can pass the wallet address as the fallback option if that's the case.
|
||||
*
|
||||
* If not passed, the component will return `null`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AccountName fallbackComponent={"0x1234....3f3f"} />
|
||||
* ```
|
||||
*/
|
||||
fallbackComponent?: JSX.Element;
|
||||
/**
|
||||
* Optional `useQuery` params
|
||||
*/
|
||||
queryOptions?: Omit<UseQueryOptions<string>, "queryKey" | "queryFn">;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is used to display the name of the account.
|
||||
* A "name" in this context is the username, or account of the social profiles that the wallet may have.
|
||||
* In case a name is not found or failed to resolve, you can always fallback to displaying the wallet address instead by using the `fallbackComponent` prop.
|
||||
*
|
||||
* This component inherits all attribute of a native HTML <span> element, so you can style it just like how you would style a <span>.
|
||||
*
|
||||
* @param props
|
||||
* @returns A `<span>` containing the name of the account
|
||||
* ```html
|
||||
* <span>{name}</span>
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ### Basic usage
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountName } from "thirdweb/react";
|
||||
*
|
||||
* <AccountProvider address="0x1234...3f3f" client={client}>
|
||||
* <AccountName />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
*
|
||||
* ### Show wallet address while social name is being loaded
|
||||
* ```tsx
|
||||
* <AccountName
|
||||
* loadingComponent={<AccountAddress />}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* ### Fallback to showing wallet address if fail to resolve social name
|
||||
* ```tsx
|
||||
* <AccountName
|
||||
* fallbackComponent={<AccountAddress />}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* ### Transform the account name using `formatFn` prop
|
||||
* ```tsx
|
||||
* import { isAddress, shortenAddress } from "thirdweb/utils";
|
||||
* import { AccountProvider, AccountName } from "thirdweb/react";
|
||||
*
|
||||
* // Let's say we want the name to be capitalized without using CSS
|
||||
* const formatName = (name: string) => name.toUpperCase();
|
||||
*
|
||||
* return <AccountName formatFn={formatName} />
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* ### Custom query options for useQuery
|
||||
* This component uses `@tanstack-query`'s useQuery internally.
|
||||
* You can use the `queryOptions` prop for more fine-grained control
|
||||
* ```tsx
|
||||
* <AccountName
|
||||
* queryOptions={{
|
||||
* enabled: isEnabled,
|
||||
* retry: 3
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @component
|
||||
* @account
|
||||
* @beta
|
||||
*/
|
||||
export function AccountName({
|
||||
resolverAddress,
|
||||
resolverChain,
|
||||
socialType,
|
||||
formatFn,
|
||||
queryOptions,
|
||||
loadingComponent,
|
||||
fallbackComponent,
|
||||
...restProps
|
||||
}: AccountNameProps) {
|
||||
const { address, client } = useAccountContext();
|
||||
const nameQuery = useQuery({
|
||||
queryKey: ["account-name", address],
|
||||
queryFn: async () => {
|
||||
const [socialData, ensName] = await Promise.all([
|
||||
getSocialProfiles({ address, client }),
|
||||
resolveName({
|
||||
client,
|
||||
address,
|
||||
resolverAddress,
|
||||
resolverChain,
|
||||
}),
|
||||
]);
|
||||
|
||||
const name =
|
||||
socialData?.filter(
|
||||
(p) => p.name && (socialType ? p.type === socialType : true),
|
||||
)[0]?.name || ensName;
|
||||
|
||||
if (!name) {
|
||||
throw new Error("Failed to resolve account name");
|
||||
}
|
||||
return formatFn ? formatFn(name) : name;
|
||||
},
|
||||
...queryOptions,
|
||||
});
|
||||
|
||||
if (nameQuery.isLoading) {
|
||||
return loadingComponent || null;
|
||||
}
|
||||
|
||||
if (!nameQuery.data) {
|
||||
return fallbackComponent || null;
|
||||
}
|
||||
|
||||
return <span {...restProps}>{nameQuery.data}</span>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "~test/react-render.js";
|
||||
import { TEST_CLIENT } from "~test/test-clients.js";
|
||||
import { AccountAddress } from "./address.js";
|
||||
import { AccountProvider } from "./provider.js";
|
||||
|
||||
describe.runIf(process.env.TW_SECRET_KEY)("AccountProvider component", () => {
|
||||
it("should render children correctly", () => {
|
||||
render(
|
||||
<AccountProvider
|
||||
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
|
||||
client={TEST_CLIENT}
|
||||
>
|
||||
<div>Child Component</div>
|
||||
</AccountProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Child Component")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass the address correctly to the children props", () => {
|
||||
render(
|
||||
<AccountProvider
|
||||
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
|
||||
client={TEST_CLIENT}
|
||||
>
|
||||
<AccountAddress />
|
||||
</AccountProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("0x12345674b599ce99958242b3D3741e7b01841DF3", {
|
||||
exact: true,
|
||||
selector: "span",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import type { Address } from "abitype";
|
||||
import type React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ThirdwebClient } from "../../../../../client/client.js";
|
||||
|
||||
/**
|
||||
* Props for the <AccountProvider /> component
|
||||
* @component
|
||||
* @account
|
||||
*/
|
||||
export type AccountProviderProps = {
|
||||
/**
|
||||
* The user's wallet address
|
||||
*/
|
||||
address: Address;
|
||||
/**
|
||||
* thirdweb Client
|
||||
*/
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
const AccountProviderContext = /* @__PURE__ */ createContext<
|
||||
AccountProviderProps | undefined
|
||||
>(undefined);
|
||||
|
||||
/**
|
||||
* A React context provider component that supplies Account-related data to its child components.
|
||||
*
|
||||
* This component serves as a wrapper around the `AccountProviderContext.Provider` and passes
|
||||
* the provided account data down to all of its child components through the context API.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { AccountProvider, AccountAvatar, AccountName, AccountAddress } from "thirdweb/react";
|
||||
*
|
||||
* <AccountProvider>
|
||||
* <AccountAvatar />
|
||||
* <AccountName />
|
||||
* <AccountAddress />
|
||||
* </AccountProvider>
|
||||
* ```
|
||||
*
|
||||
* @component
|
||||
* @account
|
||||
* @beta
|
||||
*/
|
||||
export function AccountProvider(
|
||||
props: React.PropsWithChildren<AccountProviderProps>,
|
||||
) {
|
||||
return (
|
||||
<AccountProviderContext.Provider value={props}>
|
||||
{props.children}
|
||||
</AccountProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function useAccountContext() {
|
||||
const ctx = useContext(AccountProviderContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"AccountProviderContext not found. Make sure you are using AccountName, AccountAvatar, etc. inside an <AccountProvider /> component",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export interface NFTDescriptionProps
|
||||
*
|
||||
* @component
|
||||
* @nft
|
||||
* @beta
|
||||
*/
|
||||
export function NFTDescription({
|
||||
loadingComponent,
|
||||
|
||||
@@ -77,6 +77,7 @@ export type NFTMediaProps = Omit<
|
||||
* <NFTMedia style={{ borderRadius: "8px" }} className="mx-auto" />
|
||||
* ```
|
||||
* @nft
|
||||
* @beta
|
||||
*/
|
||||
export function NFTMedia({
|
||||
loadingComponent,
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface NFTNameProps
|
||||
* ```
|
||||
*
|
||||
* @nft
|
||||
* @beta
|
||||
*/
|
||||
export function NFTName({
|
||||
loadingComponent,
|
||||
|
||||
@@ -43,7 +43,6 @@ export function useNFTContext() {
|
||||
* the provided NFT data down to all of its child components through the context API.
|
||||
*
|
||||
*
|
||||
* @component
|
||||
* @param {React.PropsWithChildren<NFTProviderProps>} props - The props for the NFT provider
|
||||
*
|
||||
* @example
|
||||
@@ -63,7 +62,9 @@ export function useNFTContext() {
|
||||
* <NFTName />
|
||||
* </NFTProvider>
|
||||
* ```
|
||||
* @component
|
||||
* @nft
|
||||
* @beta
|
||||
*/
|
||||
export function NFTProvider(props: React.PropsWithChildren<NFTProviderProps>) {
|
||||
return (
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
{
|
||||
"tagName": "@nft",
|
||||
"syntaxKind": "block"
|
||||
},
|
||||
{
|
||||
"tagName": "@account",
|
||||
"syntaxKind": "block"
|
||||
}
|
||||
],
|
||||
"supportForTags": {
|
||||
@@ -107,6 +111,8 @@
|
||||
"@modules": true,
|
||||
"@social": true,
|
||||
"@client": true,
|
||||
"@nft": true
|
||||
"@nft": true,
|
||||
"@account": true,
|
||||
"@beta": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user