From f83eb1bbaaa5228fd7addd805405f163c735d7c1 Mon Sep 17 00:00:00 2001 From: I Putu Saputrayana Date: Mon, 28 Apr 2025 10:29:48 +0800 Subject: [PATCH] feat: draft for erc721 card --- .../src/stories/nft/NFTCard.stories.tsx | 41 ++++++++++ .../components/nft/components/nft-content.tsx | 16 ++++ .../components/nft/components/nft-media.tsx | 21 ++++++ .../components/nft/components/nft-owner.tsx | 30 ++++++++ .../components/nft/components/nft-title.tsx | 19 +++++ .../components/nft/components/provider.tsx | 45 +++++++++++ .../ui-react/src/components/nft/nft-card.tsx | 62 ++++++++++++++++ packages/ui-react/src/components/nft/types.ts | 6 ++ packages/ui-react/src/components/nft/utils.ts | 3 + .../ui-react/src/hooks/data/use-blockscout.ts | 15 ++++ packages/ui-react/src/lib/blockscout/api.ts | 11 +++ packages/ui-react/src/lib/blockscout/types.ts | 74 +++++++++++++++++++ 12 files changed, 343 insertions(+) create mode 100644 apps/storybook/src/stories/nft/NFTCard.stories.tsx create mode 100644 packages/ui-react/src/components/nft/components/nft-content.tsx create mode 100644 packages/ui-react/src/components/nft/components/nft-media.tsx create mode 100644 packages/ui-react/src/components/nft/components/nft-owner.tsx create mode 100644 packages/ui-react/src/components/nft/components/nft-title.tsx create mode 100644 packages/ui-react/src/components/nft/components/provider.tsx create mode 100644 packages/ui-react/src/components/nft/nft-card.tsx create mode 100644 packages/ui-react/src/components/nft/types.ts create mode 100644 packages/ui-react/src/components/nft/utils.ts create mode 100644 packages/ui-react/src/lib/blockscout/types.ts diff --git a/apps/storybook/src/stories/nft/NFTCard.stories.tsx b/apps/storybook/src/stories/nft/NFTCard.stories.tsx new file mode 100644 index 00000000..290487de --- /dev/null +++ b/apps/storybook/src/stories/nft/NFTCard.stories.tsx @@ -0,0 +1,41 @@ +import { NFTCard as NFTCard$ } from "@geist/ui-react/components/nft/nft-card"; +import type { Meta, StoryObj } from "@storybook/react"; +import { withWagmiProvider } from "#stories/decorators/wagmi.tsx"; + +interface ERC721CardStoriesProps { + tokenId: string; + contractAddress: string; +} + +function ERC721CardStories({ + tokenId, + contractAddress, +}: ERC721CardStoriesProps) { + return ( + + ); +} + +const meta = { + title: "NFT/ERC721Card", + component: ERC721CardStories, + parameters: { + layout: "centered", + }, + decorators: [withWagmiProvider()], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ERC721Card: Story = { + args: { + tokenId: "6273", + contractAddress: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8", + }, + parameters: {}, +}; diff --git a/packages/ui-react/src/components/nft/components/nft-content.tsx b/packages/ui-react/src/components/nft/components/nft-content.tsx new file mode 100644 index 00000000..6d286902 --- /dev/null +++ b/packages/ui-react/src/components/nft/components/nft-content.tsx @@ -0,0 +1,16 @@ +import { CardContent } from "#components/shadcn/card.js"; +import { cn } from "#lib/shadcn/utils.js"; +import React from "react"; + +export const NFTContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); + +NFTContent.displayName = "NFTContent"; diff --git a/packages/ui-react/src/components/nft/components/nft-media.tsx b/packages/ui-react/src/components/nft/components/nft-media.tsx new file mode 100644 index 00000000..d78f243b --- /dev/null +++ b/packages/ui-react/src/components/nft/components/nft-media.tsx @@ -0,0 +1,21 @@ +import { useNFT } from "./provider"; + +export function NFTMedia() { + const { nftData } = useNFT(); + + const imageUrl = nftData?.image_url; + + if (!imageUrl) { + return
; + } + + return ( +
+ {nftData?.metadata?.name +
+ ); +} diff --git a/packages/ui-react/src/components/nft/components/nft-owner.tsx b/packages/ui-react/src/components/nft/components/nft-owner.tsx new file mode 100644 index 00000000..afb9461c --- /dev/null +++ b/packages/ui-react/src/components/nft/components/nft-owner.tsx @@ -0,0 +1,30 @@ +import { CardDescription, CardTitle } from "#components/shadcn/card.js"; +import { cn } from "#lib/shadcn/utils.js"; +import React from "react"; +import { useNFT } from "./provider"; +import { formatAddress } from "../utils"; + +export const NFTOwner = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { nftData } = useNFT(); + + return ( +
+ Owned by + + {nftData?.owner?.ens_domain_name ?? + formatAddress(nftData?.owner?.hash ?? "")} + +
+ ); +}); + +NFTOwner.displayName = "NFTOwner"; diff --git a/packages/ui-react/src/components/nft/components/nft-title.tsx b/packages/ui-react/src/components/nft/components/nft-title.tsx new file mode 100644 index 00000000..1e917b9b --- /dev/null +++ b/packages/ui-react/src/components/nft/components/nft-title.tsx @@ -0,0 +1,19 @@ +import { CardTitle } from "#components/shadcn/card.js"; +import { cn } from "#lib/shadcn/utils.js"; +import React from "react"; +import { useNFT } from "./provider"; + +export const NFTTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { nftData } = useNFT(); + + return ( + + {nftData?.metadata?.name} + + ); +}); + +NFTTitle.displayName = "NFTTitle"; diff --git a/packages/ui-react/src/components/nft/components/provider.tsx b/packages/ui-react/src/components/nft/components/provider.tsx new file mode 100644 index 00000000..e7dca6e2 --- /dev/null +++ b/packages/ui-react/src/components/nft/components/provider.tsx @@ -0,0 +1,45 @@ +import { + createContext, + useContext, + useMemo, + type PropsWithChildren, +} from "react"; +import type { Address } from "viem"; +import type { NFTCardType } from "../types"; +import { useGetSingleNFTMetadata } from "#hooks/data/use-blockscout.js"; +import type { NFTResponse } from "#lib/blockscout/types.js"; + +export type NFTContextType = { + nftData: NFTResponse | undefined; + loading: boolean; + error: Error | null; +}; + +export const NFTContext = createContext(undefined as never); + +export const NFTProvider = ({ + children, + contractAddress, + tokenId, +}: PropsWithChildren) => { + const { + data: nftData, + isLoading: loading, + error, + } = useGetSingleNFTMetadata(contractAddress, tokenId); + + const value = useMemo( + () => ({ nftData: nftData, loading, error }), + [nftData, loading, error], + ); + + return {children}; +}; + +export const useNFT = () => { + const context = useContext(NFTContext); + if (!context) { + throw new Error("useNFT must be used within a NFTProvider"); + } + return context; +}; diff --git a/packages/ui-react/src/components/nft/nft-card.tsx b/packages/ui-react/src/components/nft/nft-card.tsx new file mode 100644 index 00000000..adf2e6f3 --- /dev/null +++ b/packages/ui-react/src/components/nft/nft-card.tsx @@ -0,0 +1,62 @@ +import { Card } from "#components/shadcn/card.js"; +import { cn } from "#lib/shadcn/utils.js"; +import React, { type HTMLAttributes, type PropsWithChildren } from "react"; +import { NFTProvider } from "./components/provider"; +import type { Address } from "viem"; +import { NFTMedia } from "./components/nft-media"; +import { NFTTitle } from "./components/nft-title"; +import { NFTContent } from "./components/nft-content"; +import { NFTOwner } from "./components/nft-owner"; + +function NFTCardDefaultContent() { + return ( + <> + + + + + + + ); +} + +interface NFTCardProps extends HTMLAttributes { + tokenId: string; + contractAddress: string; +} + +export const NFTCard = React.forwardRef< + HTMLDivElement, + PropsWithChildren +>( + ( + { + tokenId, + contractAddress, + children = , + className, + ...props + }, + ref, + ) => { + return ( + + + {children} + + + ); + }, +); + +NFTCard.displayName = "NFTCard"; diff --git a/packages/ui-react/src/components/nft/types.ts b/packages/ui-react/src/components/nft/types.ts new file mode 100644 index 00000000..c8e212be --- /dev/null +++ b/packages/ui-react/src/components/nft/types.ts @@ -0,0 +1,6 @@ +import type { Address } from "viem"; + +export type NFTCardType = { + contractAddress: Address; + tokenId: string; +}; diff --git a/packages/ui-react/src/components/nft/utils.ts b/packages/ui-react/src/components/nft/utils.ts new file mode 100644 index 00000000..638dfdb7 --- /dev/null +++ b/packages/ui-react/src/components/nft/utils.ts @@ -0,0 +1,3 @@ +export const formatAddress = (address: string) => { + return address.slice(0, 6) + "..." + address.slice(-4); +}; diff --git a/packages/ui-react/src/hooks/data/use-blockscout.ts b/packages/ui-react/src/hooks/data/use-blockscout.ts index fe80d0cf..a3061e75 100644 --- a/packages/ui-react/src/hooks/data/use-blockscout.ts +++ b/packages/ui-react/src/hooks/data/use-blockscout.ts @@ -3,9 +3,11 @@ import { useQuery } from "@tanstack/react-query"; import { type GetTxnByFilterQuery, asTransactionMeta, + getSingleNFTMetadata, getTransaction, getTxnsByFilter, } from "#lib/blockscout/api"; +import type { Address } from "viem"; export const CACHE_KEY = "blockscout"; @@ -30,3 +32,16 @@ export const useGetTransactions = (query: GetTxnByFilterQuery) => { }, }); }; + +export const useGetSingleNFTMetadata = ( + contractAddress: Address, + tokenId: string, +) => { + return useQuery({ + queryKey: [`${CACHE_KEY}.transactions`, contractAddress, tokenId], + queryFn: async () => { + const results = await getSingleNFTMetadata(contractAddress, tokenId); + return results; + }, + }); +}; diff --git a/packages/ui-react/src/lib/blockscout/api.ts b/packages/ui-react/src/lib/blockscout/api.ts index 4cf7a618..33618338 100644 --- a/packages/ui-react/src/lib/blockscout/api.ts +++ b/packages/ui-react/src/lib/blockscout/api.ts @@ -4,6 +4,7 @@ import type { } from "@geist/domain/transaction/transaction"; import { type Address, parseUnits } from "viem"; import * as chains from "viem/chains"; +import type { NFTResponse } from "./types"; const chainIdToApiRoot: any = { [chains.mainnet.id]: "https://eth.blockscout.com/api/", @@ -82,6 +83,16 @@ export const getTransaction = async (txnHash: string, chainId?: number) => { return invokeApi(endpoint); }; +export const getSingleNFTMetadata = async ( + contractAddress: Address, + tokenId: string, + chainId?: number, +) => { + return invokeApi( + `${chainIdToApiRoot[chainId || chains.mainnet.id]}v2/tokens/${contractAddress}/instances/${tokenId}`, + ) as Promise; +}; + export interface GetTxnByFilterQuery { filter?: string; diff --git a/packages/ui-react/src/lib/blockscout/types.ts b/packages/ui-react/src/lib/blockscout/types.ts new file mode 100644 index 00000000..93e811bd --- /dev/null +++ b/packages/ui-react/src/lib/blockscout/types.ts @@ -0,0 +1,74 @@ +export interface NFTResponse { + is_unique: boolean; + id: string; + holder_address_hash?: string; + image_url?: string; + animation_url?: string; + external_app_url?: string; + metadata?: NFTResponseMetadata; + owner: Owner; + token: Token; +} + +export interface NFTResponseMetadata { + year?: number; + tags?: string[]; + name?: string; + image_url?: string; + home_url?: string; + external_url?: string; + description?: string; + attributes?: Attribute[]; +} + +export interface Attribute { + value: string; + trait_type: string; +} + +export interface Owner { + hash: string; + implementation_name: string; + name: string; + ens_domain_name?: string; + metadata?: OwnerMetadata; + is_contract: boolean; + private_tags: Tag[]; + watchlist_names: WatchlistName[]; + public_tags: Tag[]; + is_verified: boolean; +} + +export interface OwnerMetadata { + slug: string; + name: string; + tagType: string; + ordinal: number; + meta: Meta; +} + +export interface Meta {} + +export interface Tag { + address_hash: string; + display_name: string; + label: string; +} + +export interface WatchlistName { + display_name: string; + label: string; +} + +export interface Token { + circulating_market_cap: string; + icon_url: string; + name: string; + decimals: string; + symbol: string; + address: string; + type: string; + holders: string; + exchange_rate: string; + total_supply: string; +}