Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/storybook/src/stories/nft/NFTCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -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({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we create 2 stories for 2 separate smart vs dumb components
(similar to TransactionTable vs TransactionTableWithDetails

end goal is we don't dictate the data source

tokenId,
contractAddress,
}: ERC721CardStoriesProps) {
return (
<NFTCard$
tokenId={tokenId}
contractAddress={contractAddress}
className="max-w-96 min-w-40"
/>
);
}

const meta = {
title: "NFT/ERC721Card",
component: ERC721CardStories,
parameters: {
layout: "centered",
},
decorators: [withWagmiProvider()],
} satisfies Meta<typeof ERC721CardStories>;

export default meta;
type Story = StoryObj<typeof meta>;

export const ERC721Card: Story = {
args: {
tokenId: "6273",
contractAddress: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8",
},
parameters: {},
};
16 changes: 16 additions & 0 deletions packages/ui-react/src/components/nft/components/nft-content.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>
>(({ className, ...props }, ref) => (
<CardContent
ref={ref}
className={cn("pt-6 space-y-2", className)}
{...props}
/>
));

NFTContent.displayName = "NFTContent";
21 changes: 21 additions & 0 deletions packages/ui-react/src/components/nft/components/nft-media.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useNFT } from "./provider";

export function NFTMedia() {
const { nftData } = useNFT();

const imageUrl = nftData?.image_url;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@iyansr do you hv example data for blockscout nft url?
many NFT provide ipfs hash so we need explicit ipfs gateway control on the image to be shown

(reference getIpfsGatewayUrl)


if (!imageUrl) {
return <div className="aspect-square bg-neutral-200" />;
}

return (
<div className="aspect-square">
<img
src={imageUrl}
alt={nftData?.metadata?.name ?? ""}
className="object-cover h-full w-full"
/>
</div>
);
}
30 changes: 30 additions & 0 deletions packages/ui-react/src/components/nft/components/nft-owner.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { nftData } = useNFT();

return (
<div
className={cn("flex items-center justify-between", className)}
{...props}
>
<CardDescription ref={ref}>Owned by</CardDescription>
<CardDescription
ref={ref}
className="text-right font-semibold text-black"
>
{nftData?.owner?.ens_domain_name ??
formatAddress(nftData?.owner?.hash ?? "")}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets use existing components / utils
we do hv

</CardDescription>
</div>
);
});

NFTOwner.displayName = "NFTOwner";
19 changes: 19 additions & 0 deletions packages/ui-react/src/components/nft/components/nft-title.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { nftData } = useNFT();

return (
<CardTitle ref={ref} className={cn(className)} {...props}>
{nftData?.metadata?.name}
</CardTitle>
);
});

NFTTitle.displayName = "NFTTitle";
45 changes: 45 additions & 0 deletions packages/ui-react/src/components/nft/components/provider.tsx
Original file line number Diff line number Diff line change
@@ -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<NFTContextType>(undefined as never);

export const NFTProvider = ({
children,
contractAddress,
tokenId,
}: PropsWithChildren<NFTCardType>) => {
const {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this case it's fine while generally we might prefer nanostore over context provider
https://github.com/nanostores/nanostores

data: nftData,
isLoading: loading,
error,
} = useGetSingleNFTMetadata(contractAddress, tokenId);

const value = useMemo(
() => ({ nftData: nftData, loading, error }),
[nftData, loading, error],
);

return <NFTContext.Provider value={value}>{children}</NFTContext.Provider>;
};

export const useNFT = () => {
const context = useContext(NFTContext);
if (!context) {
throw new Error("useNFT must be used within a NFTProvider");
}
return context;
};
62 changes: 62 additions & 0 deletions packages/ui-react/src/components/nft/nft-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<NFTMedia />
<NFTContent>
<NFTTitle />
<NFTOwner />
</NFTContent>
</>
);
}

interface NFTCardProps extends HTMLAttributes<HTMLDivElement> {
tokenId: string;
contractAddress: string;
}

export const NFTCard = React.forwardRef<
HTMLDivElement,
PropsWithChildren<NFTCardProps>
>(
(
{
tokenId,
contractAddress,
children = <NFTCardDefaultContent />,
className,
...props
},
ref,
) => {
return (
<NFTProvider
contractAddress={contractAddress as Address}
tokenId={tokenId}
>
<Card
ref={ref}
className={cn(
"flex w-full max-w-[500px] flex-col items-stretch gap-1.5 border overflow-hidden text-left",
className,
)}
{...props}
>
{children}
</Card>
</NFTProvider>
);
},
);

NFTCard.displayName = "NFTCard";
6 changes: 6 additions & 0 deletions packages/ui-react/src/components/nft/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { Address } from "viem";

export type NFTCardType = {
contractAddress: Address;
tokenId: string;
};
3 changes: 3 additions & 0 deletions packages/ui-react/src/components/nft/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const formatAddress = (address: string) => {
return address.slice(0, 6) + "..." + address.slice(-4);
};
15 changes: 15 additions & 0 deletions packages/ui-react/src/hooks/data/use-blockscout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
},
});
};
11 changes: 11 additions & 0 deletions packages/ui-react/src/lib/blockscout/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down Expand Up @@ -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<NFTResponse>;
};

export interface GetTxnByFilterQuery {
filter?: string;

Expand Down
74 changes: 74 additions & 0 deletions packages/ui-react/src/lib/blockscout/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export interface NFTResponse {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can put types into the api.ts

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;
}
Loading