Nft Portfolio

Dashboard to view NFT holdings using a connected wallet or given address

NftsAnalyticsData
Loading

Technologies

Installation

Install Web3 dependencies

npm install @reown/appkit @reown/appkit-adapter-wagmi wagmi viem @tanstack/react-query alchemy-sdk

Add Shadcn components

npx shadcn@latest add card input skeleton button

Add utils file

Create a file named lib/utils.ts and add the following code:
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
  
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Add custom hook

Create a file named hooks/useFetchUserNfts.ts and add the following code:
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
  Alchemy,
  Network,
  OwnedNft,
  GetNftsForOwnerOptions,
} from "alchemy-sdk";
import { isAddress, getAddress } from "viem";

const settings = {
  apiKey: process.env.ALCHEMY_API_KEY, // Remember to setup your Alchemy API Key.
  network: Network.ETH_MAINNET, // Replace with your network.
};

const alchemy = new Alchemy(settings);

interface FetchNftsArgs {
  ownerAddress: string;
  pageKey?: string | null;
}

interface NftsResponse {
  ownedNfts: OwnedNft[];
  pageKey?: string;
  totalCount?: number;
}

const fetchNfts = async ({
  ownerAddress,
  pageKey,
}: FetchNftsArgs): Promise<NftsResponse> => {
  if (!ownerAddress) {
    throw new Error("Owner address is required");
  }

  if (!isAddress(ownerAddress)) {
    throw new Error("Invalid Ethereum address");
  }

  const checksummedAddress = getAddress(ownerAddress);

  const nfts = await alchemy.nft.getNftsForOwner(checksummedAddress, {
    pageSize: 12,
    pageKey,
  } as GetNftsForOwnerOptions);

  return {
    ownedNfts: nfts.ownedNfts,
    pageKey: nfts.pageKey,
    totalCount: nfts.totalCount,
  };
};

export const useFetchUserNftsQuery = (
  ownerAddress: string,
  pageKey?: string | null
): UseQueryResult<NftsResponse, Error> => {
  return useQuery<NftsResponse, Error>({
    queryKey: ["userNfts", pageKey],
    queryFn: () => fetchNfts({ ownerAddress, pageKey }),
    enabled: !!ownerAddress,
  });
};

Copy and paste source code

"use client";

import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import Image from "next/image";
import { useFetchUserNftsQuery } from "@/hooks/useFetchUserNfts";
import { Skeleton } from "@/components/ui/skeleton";
import { useAccount } from "wagmi";
import { isAddress, getAddress } from "viem";
import { cn } from "@/lib/utils";
import { SearchXIcon, CircleXIcon } from "lucide-react";

const fallbackImage = "https://opensea.io/static/images/placeholder.png";

const NftPortfolioDashboard = () => {
  const [mounted, setMounted] = useState<boolean>(false);
  const [pageKeys, setPageKeys] = useState<string[]>([""]);
  const [currentPageIndex, setCurrentPageIndex] = useState<number>(0);
  const [activePageKey, setActivePageKey] = useState<string>("");
  const [totalNfts, setTotalNfts] = useState<number | null>(null);
  const [queryAddress, setQueryAddress] = useState<string>("");
  const [queryAddressInvalid, setQueryAddressInvalid] =
    useState<boolean>(false);

  const { isConnected, isReconnecting, address } = useAccount();

  const { data, isLoading, isSuccess, isError } = useFetchUserNftsQuery(
    isConnected && address ? address : queryAddress,
    activePageKey
  );

  useEffect(() => {
    if (isSuccess && data.pageKey) {
      setPageKeys((prevKeys) => {
        if (!prevKeys.includes(data.pageKey as string)) {
          return [...prevKeys, data.pageKey as string];
        }
        return prevKeys;
      });
    }

    if (isSuccess && data.totalCount && totalNfts === null) {
      setTotalNfts(data.totalCount);
    }
  }, [isSuccess, data, totalNfts]);

  const handleNextClick = () => {
    if (currentPageIndex < pageKeys.length - 1) {
      setCurrentPageIndex((prevIndex) => prevIndex + 1);
      setActivePageKey(pageKeys[currentPageIndex + 1]);
    }
  };

  const handlePrevClick = () => {
    if (currentPageIndex > 0) {
      setCurrentPageIndex((prevIndex) => prevIndex - 1);
      setActivePageKey(pageKeys[currentPageIndex - 1]);
    } else {
      setActivePageKey("");
    }
  };

  const handleInputChange = (e: string) => {
    const newVal = e;

    if (newVal === "") {
      setQueryAddressInvalid(false);
      setQueryAddress("");
      return;
    }

    const isValidAddress = isAddress(newVal);

    if (!isValidAddress) {
      setQueryAddressInvalid(true);
      setQueryAddress(newVal);
    } else {
      setQueryAddressInvalid(false);
      setQueryAddress(getAddress(newVal));
    }
  };

  const shortenAddress = (address: `0x${string}` | undefined) => {
    if (!address) return "";

    return `${address.slice(0, 4)}...${address.slice(-4)}`;
  };

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted)
    return (
      <div className="w-full h-56 flex items-center">
        <div className="flex justify-center items-center gap-2 w-full h-44 text-[#A1A1AA]">
          <div className="w-3.5 h-3.5 border-2 border-gray-400 border-b-transparent rounded-full inline-block box-border animate-spin" />
          Loading
        </div>
      </div>
    );

  return (
    <div
      className={cn(
        "w-full min-h-screen bg-[#0a0a0a] p-10 text-black border-[0.5px] border-white/10",
        // *INFO - This is only used to make the component look better in preview mode
        "rounded-2xl"
      )}
    >
      {(isConnected && address && !isReconnecting) ||
      (queryAddress.length > 0 && !queryAddressInvalid) ? (
        <div className=" max-w-[76rem] mx-auto flex flex-col gap-10 text-white">
          <div className="flex justify-between items-start font-semibold border-b border-white/10 pb-4">
            <div>
              <h2 className="">Hello ser</h2>
              <h1 className=" text-4xl ">
                {shortenAddress(
                  queryAddress.length > 0 && !queryAddressInvalid
                    ? (queryAddress as `0x${string}`)
                    : address
                )}
              </h1>
            </div>

            <div className="items-center hidden md:flex">
              <span className="text-3xl">{totalNfts}</span>
              <Image
                src="https://cdn3d.iconscout.com/3d/premium/thumb/nft-3d-illustration-download-in-png-blend-fbx-gltf-file-formats--nonfungible-token-tokens-crypto-pack-cryptocurrency-illustrations-3061789.png?f=webp"
                alt="Profile Picture"
                width={70}
                height={70}
                className="rounded-full"
              />
            </div>
          </div>
          <div className="flex flex-wrap gap-10">
            {isLoading ? (
              Array.from({ length: 12 }).map((_, index) => (
                <NftSkeletonCard key={index} />
              ))
            ) : isSuccess && data.ownedNfts.length > 0 ? (
              data.ownedNfts.map((nft) => (
                <NftAssetCard
                  key={nft.tokenId}
                  imageUrl={nft.image.pngUrl ? nft.image.pngUrl : fallbackImage}
                  collectionName={nft.collection?.name}
                  nftName={nft.name}
                  tokenId={nft.tokenId}
                />
              ))
            ) : isSuccess && data.ownedNfts.length === 0 ? (
              <div className="flex items-center gap-2">
                <span>No NFTs found for this wallet address</span>
                <SearchXIcon />
              </div>
            ) : isError ? (
              <div className="flex items-center gap-2">
                <span>
                  An error has occured fetching the NFTs for this wallet address
                </span>
                <CircleXIcon />
              </div>
            ) : (
              <></>
            )}
          </div>

          <div>
            <div className="h-[0.5px] bg-white/15 w-full" />

            <div className="flex justify-between items-center mt-4">
              <div className="hidden sm:block ">
                <p className="text-sm text-gray-200">
                  Showing{" "}
                  <span className="font-medium">
                    {currentPageIndex * 12 + 1}
                  </span>{" "}
                  to{" "}
                  <span className="font-medium">
                    {Math.min((currentPageIndex + 1) * 12, totalNfts || 0)}
                  </span>{" "}
                  of <span className="font-medium">{totalNfts}</span> results
                </p>
              </div>

              {totalNfts !== null && totalNfts > 12 && (
                <div className="flex gap-4">
                  <Button
                    onClick={handlePrevClick}
                    disabled={currentPageIndex === 0}
                    className=" text-white border-[0.5px] border-white/10 bg-zinc-900 hover:bg-zinc-900/80 rounded-[4px]"
                  >
                    Prev
                  </Button>
                  <Button
                    onClick={handleNextClick}
                    disabled={(currentPageIndex + 1) * 12 >= totalNfts!}
                    className=" text-white border-[0.5px] border-white/10 bg-zinc-900 hover:bg-zinc-900/80 rounded-[4px]"
                  >
                    Next
                  </Button>
                </div>
              )}
            </div>
          </div>
        </div>
      ) : (
        <div className="flex flex-col md:flex-row justify-center items-center h-screen gap-4">
          <w3m-button />
          <span className="text-white">Or</span>
          <div className="flex flex-col gap-1 relative">
            <Input
              value={queryAddress}
              onChange={(e) => handleInputChange(e.target.value)}
              placeholder="Enter an address to view NFTs"
              className={cn(
                "w-[260px] h-[40.8px] rounded-full focus-visible:ring-0 text-white",
                queryAddressInvalid ? "border-red-500" : "border-gray-600"
              )}
            />
            {queryAddressInvalid && (
              <p className="text-red-500 text-xs pl-3 absolute -bottom-5">
                Error invalid ethereum address
              </p>
            )}
          </div>
        </div>
      )}
    </div>
  );
};

export default NftPortfolioDashboard;

interface Props {
  imageUrl: string;
  collectionName: string | undefined;
  nftName: string | undefined;
  tokenId: string;
}
const NftAssetCard = ({
  imageUrl,
  collectionName,
  nftName,
  tokenId,
}: Props) => {
  const [imageError, setImageError] = useState<boolean>(false);

  return (
    <Card className="w-[270px] border-[0.5px] border-white/10 bg-zinc-900">
      <CardHeader className="p-0">
        <Image
          src={imageError ? fallbackImage : imageUrl}
          onError={() => setImageError(true)}
          alt={`${collectionName} NFT`}
          width={270}
          height={270}
          className="w-[270px] h-[270px] object-cover rounded-t-[10px]"
        />
      </CardHeader>
      <CardContent className="p-0 px-3 pt-3 pb-1 text-xs flex justify-between font-bold">
        <h4 className="truncate">{nftName}</h4>
        <p className="truncate">#{tokenId}</p>
      </CardContent>
      <CardFooter className="flex justify-between px-3 w-[200px] ">
        <p className="truncate">
          {collectionName != undefined ? collectionName : ""}
        </p>
      </CardFooter>
    </Card>
  );
};

const NftSkeletonCard = () => {
  return (
    <Card className="w-[270px]  bg-white rounded-xl ">
      <CardHeader className="p-3">
        <Skeleton className="w-full h-[244px] rounded-t-[10px] bg-slate-900/20" />
      </CardHeader>
      <CardContent className="p-3 text-sm flex justify-between">
        <Skeleton className=" h-4 w-32 rounded-xl bg-slate-900/20" />
        <Skeleton className=" h-4 w-10 rounded-xl bg-slate-900/20" />
      </CardContent>
      <CardFooter className="px-3">
        <Skeleton className=" h-4 w-full rounded-xl bg-slate-900/20" />
      </CardFooter>
    </Card>
  );
};

See something you like?

Take your project further with our advanced custom development.

"One of the only full-stack Web3 component libraries i've seen in the space so far. Ten out of ten recommended. Saved me a ton of time. Can't wait to see what templates they release next."

Samy

Side projects builder