diff --git a/.changeset/clever-carrots-march.md b/.changeset/clever-carrots-march.md new file mode 100644 index 00000000000..e85a2296560 --- /dev/null +++ b/.changeset/clever-carrots-march.md @@ -0,0 +1,5 @@ +--- +"thirdweb": minor +--- + +Add headless components: ChainProvider, ChainIcon & ChainName diff --git a/apps/portal/src/app/react/v5/components/onchain/page.mdx b/apps/portal/src/app/react/v5/components/onchain/page.mdx index fc36af804d2..17c72de5d27 100644 --- a/apps/portal/src/app/react/v5/components/onchain/page.mdx +++ b/apps/portal/src/app/react/v5/components/onchain/page.mdx @@ -113,4 +113,27 @@ Build your own UI and interact with onchain data using headless components. description="Component to display the description of an NFT" /> +### Chains + + + + + + + diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index c41928f6f42..65496c90206 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -253,3 +253,17 @@ export { TokenIcon, type TokenIconProps, } from "../react/web/ui/prebuilt/Token/icon.js"; + +// Chain +export { + ChainProvider, + type ChainProviderProps, +} from "../react/web/ui/prebuilt/Chain/provider.js"; +export { + ChainName, + type ChainNameProps, +} from "../react/web/ui/prebuilt/Chain/name.js"; +export { + ChainIcon, + type ChainIconProps, +} from "../react/web/ui/prebuilt/Chain/icon.js"; diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/icon.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/icon.tsx new file mode 100644 index 00000000000..356fe69b58b --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/icon.tsx @@ -0,0 +1,154 @@ +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type { JSX } from "react"; +import { getChainMetadata } from "../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { resolveScheme } from "../../../../../utils/ipfs.js"; +import { useChainContext } from "./provider.js"; + +/** + * Props for the ChainIcon component + * @chain + * @component + */ +export interface ChainIconProps + extends Omit, "src"> { + /** + * You need a ThirdwebClient to resolve the icon which is hosted on IPFS. + * (since most chain icons are hosted on IPFS, loading them via thirdweb gateway will ensure better performance) + */ + client: ThirdwebClient; + /** + * This prop can be a string or a (async) function that resolves to a string, representing the icon url of the chain + * This is particularly useful if you already have a way to fetch the chain icon. + */ + iconResolver?: string | (() => string) | (() => Promise); + /** + * This component will be shown while the avatar of the icon is being fetched + * If not passed, the component will return `null`. + * + * You can pass a loading sign or spinner to this prop. + * @example + * ```tsx + * } /> + * ``` + */ + 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 + * } /> + * ``` + */ + fallbackComponent?: JSX.Element; + + /** + * Optional query options for `useQuery` + */ + queryOptions?: Omit, "queryFn" | "queryKey">; +} + +/** + * This component tries to resolve the icon of a given chain, then return an image. + * @returns an with the src of the chain icon + * + * @example + * ### Basic usage + * ```tsx + * import { ChainProvider, ChainIcon } from "thirdweb/react"; + * + * + * + * + * ``` + * + * Result: An component with the src of the icon + * ```html + * + * ``` + * + * ### Override the icon with the `iconResolver` prop + * If you already have the icon url, you can skip the network requests and pass it directly to the ChainIcon + * ```tsx + * + * ``` + * + * You can also pass in your own custom (async) function that retrieves the icon url + * ```tsx + * const getIcon = async () => { + * const icon = getIconFromCoinMarketCap(chainId, etc); + * return icon; + * }; + * + * + * ``` + * + * ### Show a loading sign while the icon is being loaded + * ```tsx + * } /> + * ``` + * + * ### Fallback to a dummy image if the chain icon fails to resolve + * ```tsx + * } /> + * ``` + * + * ### Usage with queryOptions + * ChainIcon uses useQuery() from tanstack query internally. + * It allows you to pass a custom queryOptions of your choice for more control of the internal fetching logic + * ```tsx + * + * ``` + * + * @component + * @chain + * @beta + */ +export function ChainIcon({ + iconResolver, + loadingComponent, + fallbackComponent, + queryOptions, + client, + ...restProps +}: ChainIconProps) { + const { chain } = useChainContext(); + const iconQuery = useQuery({ + queryKey: ["_internal_chain_icon_", chain.id] as const, + queryFn: async () => { + if (typeof iconResolver === "string") { + return iconResolver; + } + if (typeof iconResolver === "function") { + return iconResolver(); + } + // Check if the chain object already has "icon" + if (chain.icon?.url) { + return chain.icon.url; + } + const possibleUrl = await getChainMetadata(chain).then( + (data) => data.icon?.url, + ); + if (!possibleUrl) { + throw new Error("Failed to resolve icon for chain"); + } + return resolveScheme({ uri: possibleUrl, client }); + }, + ...queryOptions, + }); + + if (iconQuery.isLoading) { + return loadingComponent || null; + } + + if (!iconQuery.data) { + return fallbackComponent || null; + } + + return {restProps.alt}; +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.test.tsx new file mode 100644 index 00000000000..b8278e4e635 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "~test/react-render.js"; +import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js"; +import { defineChain } from "../../../../../chains/utils.js"; +import { ChainName } from "./name.js"; +import { ChainProvider } from "./provider.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => { + it("should return the correct chain name, if the name exists in the chain object", () => { + render( + + + , + ); + waitFor(() => + expect( + screen.getByText("Ethereum", { + exact: true, + selector: "span", + }), + ).toBeInTheDocument(), + ); + }); + + it("should return the correct chain name, if the name is loaded from the server", () => { + render( + + + , + ); + waitFor(() => + expect( + screen.getByText("Ethereum Mainnet", { + exact: true, + selector: "span", + }), + ).toBeInTheDocument(), + ); + }); + + it("should return the correct FORMATTED chain name", () => { + render( + + `${str}-formatted`} /> + , + ); + waitFor(() => + expect( + screen.getByText("Ethereum-formatted", { + exact: true, + selector: "span", + }), + ).toBeInTheDocument(), + ); + }); + + it("should fallback properly when fail to resolve chain name", () => { + render( + + oops} /> + , + ); + + waitFor(() => + expect( + screen.getByText("oops", { + exact: true, + selector: "span", + }), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx new file mode 100644 index 00000000000..0bd4518128b --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type React from "react"; +import type { JSX } from "react"; +import { getChainMetadata } from "../../../../../chains/utils.js"; +import { useChainContext } from "./provider.js"; + +/** + * Props for the ChainName component + * @component + * @chain + */ +export interface ChainNameProps + extends Omit, "children"> { + /** + * This prop can be a string or a (async) function that resolves to a string, representing the name of the chain + * This is particularly useful if you already have a way to fetch the chain name. + */ + nameResolver?: string | (() => string) | (() => Promise); + /** + * A function to format the name's display value + * Particularly useful to avoid overflowing-UI issues + * + * ```tsx + * doSomething()} /> + * ``` + */ + formatFn?: (str: string) => string; + /** + * This component will be shown while the name of the chain 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 + * } /> + * ``` + */ + loadingComponent?: JSX.Element; + /** + * This component will be shown if the name 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 + * name was not fetched succesfully + * @example + * ```tsx + * + * ``` + */ + fallbackComponent?: JSX.Element; + /** + * Optional `useQuery` params + */ + queryOptions?: Omit, "queryFn" | "queryKey">; +} + +/** + * This component fetches then shows the name of a chain. + * It inherits all the attributes of a HTML component, hence you can style it just like how you would style a normal + * + * + * @example + * ### Basic usage + * ```tsx + * import { ChainProvider, ChainName } from "thirdweb/react"; + * import { ethereum } from "thirdweb/chains"; + * + * + * + * + * ``` + * Result: + * ```html + * Ethereum Mainnet + * ``` + * + * ### Custom name resolver + * By default ChainName will call the thirdweb API to retrieve the chain name. + * However if you have a different way to fetch the name, you can pass the function to the `nameResolver` prop. + * Note: nameResolver should either be a string or a function (async) that returns a string. + * ```tsx + * async function fetchNameMethod() { + * // your own fetching logic + * return "the chain name"; + * } + * + * + * ``` + * + * Alternatively you can also pass in a string directly: + * ```tsx + * + * ``` + * + * + * ### Format the name (capitalize, truncate, etc.) + * The ChainName component accepts a `formatFn` which takes in a string and outputs a string + * The function is used to modify the name of the chain + * + * ```tsx + * const concatStr = (str: string):string => str + "Network" + * + * + * + * + * ``` + * + * Result: + * ```html + * Ethereum Mainnet Network + * ``` + * + * ### Show a loading sign when the name is being fetched + * ```tsx + * import { ChainProvider, ChainName } from "thirdweb/react"; + * + * + * } /> + * + * ``` + * + * ### Fallback to something when the name fails to resolve + * ```tsx + * + * + * + * ``` + * + * ### 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 + * + * ``` + * + * @component + * @chain + * @beta + */ +export function ChainName({ + nameResolver, + formatFn, + loadingComponent, + fallbackComponent, + queryOptions, + ...restProps +}: ChainNameProps) { + const { chain } = useChainContext(); + const nameQuery = useQuery({ + queryKey: ["_internal_chain_name_", chain.id] as const, + queryFn: async () => { + if (typeof nameResolver === "string") { + return nameResolver; + } + if (typeof nameResolver === "function") { + return nameResolver(); + } + if (chain.name) { + return chain.name; + } + return getChainMetadata(chain).then((data) => data.name); + }, + ...queryOptions, + }); + + if (nameQuery.isLoading) { + return loadingComponent || null; + } + + if (!nameQuery.data) { + return fallbackComponent || null; + } + + const displayValue = formatFn ? formatFn(nameQuery.data) : nameQuery.data; + + return {displayValue}; +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.test.tsx new file mode 100644 index 00000000000..cb50b4d22e0 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "~test/react-render.js"; +import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js"; +import { ChainName } from "./name.js"; +import { ChainProvider } from "./provider.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("ChainProvider component", () => { + it("should render children correctly", () => { + render( + +
Child Component
+
, + ); + + expect(screen.getByText("Child Component")).toBeInTheDocument(); + }); + + it("should pass the chain correctly to the children props", () => { + render( + + + , + ); + + waitFor(() => + expect( + screen.getByText("Ethereum", { + exact: true, + selector: "span", + }), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.tsx new file mode 100644 index 00000000000..97c21ca52d6 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/provider.tsx @@ -0,0 +1,73 @@ +"use client"; + +import type React from "react"; +import { createContext, useContext } from "react"; +import type { Chain } from "../../../../../chains/types.js"; + +/** + * Props for the component + * @component + * @chain + */ +export type ChainProviderProps = { + chain: Chain; +}; + +const ChainProviderContext = /* @__PURE__ */ createContext< + ChainProviderProps | undefined +>(undefined); + +/** + * A React context provider component that supplies Chain-related data to its child components. + * + * This component serves as a wrapper around the `ChainProviderContext.Provider` and passes + * the provided chain data down to all of its child components through the context API. + * + * @example + * ### Basic usage + * ```tsx + * import { ChainProvider, ChainIcon, ChainName } from "thirdweb/react"; + * import { ethereum } from "thirdweb/chains"; + * + * + * + * + * + * ``` + * + * ### Usage with defineChain + * ```tsx + * import { defineChain } from "thirdweb/chains"l + * import { ChainProvider, ChainName } from "thirdweb/react"; + * + * const chainId = someNumber; + * + * + * + * + * ``` + * @component + * @chain + */ +export function ChainProvider( + props: React.PropsWithChildren, +) { + return ( + + {props.children} + + ); +} + +/** + * @internal + */ +export function useChainContext() { + const ctx = useContext(ChainProviderContext); + if (!ctx) { + throw new Error( + "ChainProviderContext not found. Make sure you are using ChainName, ChainIcon, etc. inside a component", + ); + } + return ctx; +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx index 05076a5ceaa..4c9bb4cff6a 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx @@ -7,6 +7,11 @@ import { getContractMetadata } from "../../../../../extensions/common/read/getCo import { resolveScheme } from "../../../../../utils/ipfs.js"; import { useTokenContext } from "./provider.js"; +/** + * Props for the TokenIcon component + * @component + * @token + */ export interface TokenIconProps extends Omit, "src"> { /** diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/provider.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/provider.test.tsx new file mode 100644 index 00000000000..bd7f0110ce3 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/provider.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "~test/react-render.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { TokenName } from "./name.js"; +import { TokenProvider } from "./provider.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("TokenProvider component", () => { + it("should render children correctly", () => { + render( + +
Child Component
+
, + ); + + expect(screen.getByText("Child Component")).toBeInTheDocument(); + }); + + it("should pass the token data correctly to the children props", () => { + render( + + + , + ); + + waitFor(() => + expect( + screen.getByText("Ether", { + exact: true, + selector: "span", + }), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.test.tsx new file mode 100644 index 00000000000..0a09e414c18 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "~test/react-render.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { TokenProvider } from "./provider.js"; +import { TokenSymbol } from "./symbol.js"; + +describe.runIf(process.env.TW_SECRET_KEY)("TokenSymbol component", () => { + it("should pass the address correctly to the children props", () => { + render( + + + , + ); + + waitFor(() => + expect( + screen.getByText("ETH", { + exact: true, + selector: "span", + }), + ).toBeInTheDocument(), + ); + }); +});