 
                        
                    Создайте свой собственный Ethereum NFT Explorer с помощью NextJS
1 августа 2025 г.NFT Explorer - это децентрализованное приложение (DAPP), которое позволяет пользователям получать информацию о коллекции NFT от блокчейна, такой как имя, адрес владельца и токенид.
В этом уроке мы построим исследователя на блокчейне Ethereum, используя Alchemy и NextJS. Во -первых, что такое алхимия?
С API API алхимии и его поддержкой NFT на блокчейне Ethereum, запрос данных блокчейна теперь проще, чем когда -либо.
Демо
На видео ниже показана демонстрация готового NFT Explorer, который мы собираемся построить:

Предварительные условия
В этом уроке используются следующие технологии:
- NextJs
- Tailwind CSS
- Алхимия API
Стоит также отметить, что для этого урока вам нужно будет иметь:
- Основное понимание React/nextjs
- Основное знание Tailwind CSS
- Против кода
Шаг 1 - Создайте приложение NextJS
На этом этапе мы создадим новое приложение NextJS, используяnpxМенеджер пакетов.
Введите команду ниже в свой терминал, чтобы инициировать создание нового проекта под названием NFT-Explorer:
npx create-next-app@latest nft-explorer

После того, как вы приняли установку, нам будет предложено настроить наш проект. НажмитеВведите ключ в каждую подсказку, чтобы принятьпо умолчаниюпараметры.


Затем перейдите в папку проекта с командой CD:
cd nft-explorer
Наконец, откройте свой проект в новом окне кода VS, используя команду:
code .
Шаг 2 - Создайте приложение для алхимии
Здесь мы создадим приложение для алхимии для получения нашего ключа API.
Во -первых, перейдите в алхимию, чтобы зарегистрироваться.


В последующих шагах следуйте инструкциям и введите необходимые детали.
После успешной регистрации вы будете перенаправлены на вашу панель алхимии.
- На панели инструментов нажмите кнопку «Приложения» и «Создать новое приложение»:


- Назовите свое приложение Alchemy (NFT-Explorer), напишите описание и выберите «NFTS» как «вариант использования».
- Увеличьте увеличение на случай, если вы не увидите кнопку «Далее» и нажмите «Далее»

- Выберите цепочку Ethereum.
- Увеличьте увеличение на случай, если вы не увидите кнопку «Далее», и нажмите «Далее»

- Увеличьте увеличение на случай, если вы не увидите кнопку «Создать приложение», и нажмите на нее, чтобы закончить настройку.

Шаг 3 - Подробная информация о приложении алхимии
После создания вашего приложения вы можете просмотреть данные приложения. Обратите внимание на ваш «клавиш API» и выберите сеть (Mainnet).

Шаг 4 - Установите Alchemy SDK
В вашем терминале установите Alchemy JavaScript SDK, используя команду:
npm install alchemy-sdk
Шаг 5 - Создайте свой бэкэнд -маршрут API, чтобы получить NFTS
Чтобы продолжить, нам нужно будет установить связь между нашими приложениями NextJS и Alchemy.
Создайте файл .ENV в корневом каталоге вашего проекта и сохраните свой ключ API API алхимии:
ALCHEMY_API_KEY=your_alchemy_api_key
Замените заполнителя вашим ключом API.
Используя структуру ниже, создайте файл route.ts для создания конечной точки API:
src/
├─ app/
│  ├─ api/
│  │  ├─ getnfts/
│  │  │  ├─ route.ts
В файле route.ts мы определим логику бэкэнд для получения NFT. Мы создадим асинхронную функцию для обработки входящих HTTP -запросов на наш маршрут API:
import { NextRequest, NextResponse } from "next/server";
import { Alchemy, Network } from "alchemy-sdk";
const config = {
  apiKey: process.env.ALCHEMY_API_KEY,
  network: Network.ETH_MAINNET,
  maxRetries: 3,
  requestTimeout: 30000, // 30 seconds
};
const alchemy = new Alchemy(config);
// Helper function to validate wallet address
function isValidWalletAddress(address: string): boolean {
  return /^0x[a-fA-F0-9]{40}$/.test(address);
}
// Helper function for retry logic
async function retryWithDelay<T>(
  fn: () => Promise<T>,
  retries: number = 3,
  delay: number = 1000
): Promise<T> {
  try {
    return await fn();
  } catch (error) {
    if (retries <= 0) throw error;
    
    console.log(`Retrying in ${delay}ms... (${retries} retries left)`);
    await new Promise(resolve => setTimeout(resolve, delay));
    return retryWithDelay(fn, retries - 1, delay * 2);
  }
}
export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const wallet = searchParams.get("wallet");
  if (!wallet) {
    return NextResponse.json(
      { error: "Wallet address is required" },
      { status: 400 }
    );
  }
  if (!isValidWalletAddress(wallet)) {
    return NextResponse.json(
      { error: "Invalid wallet address format" },
      { status: 400 }
    );
  }
  if (!process.env.ALCHEMY_API_KEY) {
    console.error("ALCHEMY_API_KEY is not configured");
    return NextResponse.json(
      { error: "API configuration error" },
      { status: 500 }
    );
  }
  try {
    console.log(`Fetching NFTs for wallet: ${wallet}`);
    
    const results = await retryWithDelay(
      () => alchemy.nft.getNftsForOwner(wallet, {
        excludeFilters: [], // Optional: exclude spam/airdrops
        includeFilters: [],
      }),
      3, 
      1000
    );
    console.log(`Successfully fetched ${results.ownedNfts.length} NFTs`);
    
    return NextResponse.json({ 
      message: "success", 
      data: results,
      count: results.ownedNfts.length 
    });
  } catch (error: any) {
    console.error("Alchemy error:", error);
    if (error.message?.includes("401") || error.message?.includes("authenticated")) {
      return NextResponse.json(
        { error: "API authentication failed. Please check your API key." },
        { status: 401 }
      );
    }
    if (error.code === 'ETIMEDOUT' || error.message?.includes("timeout")) {
      return NextResponse.json(
        { error: "Request timeout. The server took too long to respond." },
        { status: 408 }
      );
    }
    if (error.message?.includes("rate limit")) {
      return NextResponse.json(
        { error: "Rate limit exceeded. Please try again later." },
        { status: 429 }
      );
    }
    return NextResponse.json(
      { error: "Failed to fetch NFTs. Please try again later." },
      { status: 500 }
    );
  }
}
В приведенном выше коде:
- Объект конфигурации содержит наш ключ API и сеть, с которой мы будем взаимодействовать, в данном случае Ethereum Mainnet.
- Константа алхимии создает экземпляр алхимии SDK с использованием объекта конфигурации.
- Проверка режима isvalidwalletadddress regex гарантирует, что любой параметр кошелька выглядит похожим на шестнадцатеричную строку с 40 символом, 40 символов (адрес Ethereum).
- Вспомогательная функция retrywithdelay () повторно обрабатывает вызов API с помощьюЭкспоненциальный отборочный бакПрежде чем наконец показывать ошибки.
- Функция получить:
- Считает значение кошелька из URL и возвращает код ошибки 400, если отсутствует.
- Проверяет, что Alchemy_api_key Env установлен, в противном случае возвращает ошибку 500.
Шаг 6 - Создайте компоненты
В этом разделе мы создадим компоненты для нашего приложения NextJS.
В нашем терминале мы запустим следующую команду, чтобы запустить сервер нашего приложения:
npm run dev
Во -первых, мы обновим файл page.tsx, который будет нашей главной страницей. Скопируйте и вставьте код ниже в свой файл page.tsx.
"use client";
export default function Home() {
  return (
    <div className="h-full mt-20 p-5">
      <div className="flex flex-col gap-10">
        <div className="flex items-center justify-center">
          <h1 className="text-3xl font-bold text-gray-800">NFT EXPLORER</h1>
        </div>
        <div className="flex space-x-5 items-center justify-center">
          <input
            type="text"
            placeholder="Enter your wallet address"
            className="px-5 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
          />
          <button className="px-5 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-all cursor-pointer">
            Get NFTs
          </button>
        </div>
      </div>
    </div>
  );
}
На данный момент наша главная страница должна выглядеть так:

Создание карт NFT
Карты NFT будут отображать NFT и другиеметаданныеполучен из нашего файла route.tsx.
Чтобы создать компонент NFT Card, создайте папку компонентов в папке приложения, затем создайте новый файл nftcard.tsx.
На этом этапе, это то, на что должна выглядеть наша структура файлов:
src/
└── app/
    ├── api/
    │   └── getnfts/
    │       └── route.ts
    ├── components/
    │   └── NFTCard.tsx
    ├── favicon.ico
    ├── globals.css
    ├── layout.tsx
    └── page.tsx
После этого скопируйте и вставьте код ниже:
import { useEffect, useState } from "react";
import Image from "next/image";
const IPFS_URL = "ipfs://";
const IPFS_GATEWAY_URL = "https://ipfs.io/ipfs/";
interface ImageData {
  originalUrl?: string;
  cachedUrl?: string;
}
interface ContractData {
  address?: string;
}
interface Metadata {
  image?: string;
}
interface Data {
  image?: ImageData;
  tokenUri?: string | { raw?: string };
  contract?: ContractData;
  tokenId: string;
  name?: string;
}
interface NFTCardProps {
  data: Data;
}
export default function NFTCard({ data }: NFTCardProps) {
  const [imageUrl, setImageUrl] = useState(null);
  const [copied, setCopied] = useState(false);
  useEffect(() => {
    const resolveImageUrl = async () => {
      let rawUrl = data?.image?.originalUrl || data?.image?.cachedUrl;
      if (!rawUrl) {
        let tokenUri =
          typeof data?.tokenUri === "string"
            ? data.tokenUri
            : data?.tokenUri?.raw;
        if (tokenUri?.startsWith(IPFS_URL)) {
          tokenUri = tokenUri.replace(IPFS_URL, IPFS_GATEWAY_URL);
        }
        try {
          const res = await fetch(tokenUri);
          const metadata: Metadata = await res.json();
          rawUrl = metadata?.image;
        } catch (err) {
          console.error("Failed to load metadata:", err);
        }
      }
      if (!rawUrl) return;
      const finalUrl = rawUrl.startsWith(IPFS_URL)
        ? rawUrl.replace(IPFS_URL, IPFS_GATEWAY_URL)
        : rawUrl;
      setImageUrl(finalUrl);
    };
    resolveImageUrl();
  }, [data]);
  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(data.contract?.address || "");
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch (err) {
      console.error("Failed to copy:", err);
    }
  };
  const shortAddress = data.contract?.address
    ? data.contract.address.slice(0, 20) + "..."
    : null;
  const shortTokenId =
    data.tokenId.length > 20 ? data.tokenId.slice(0, 20) + "..." : data.tokenId;
  return (
    <div className="p-5 border rounded-lg flex flex-col">
      {imageUrl ? (
        <Image
          src={imageUrl}
          alt={data.name || "NFT Image"}
          width={500}
          height={500}
          unoptimized
        />
      ) : (
        <div className="w-full h-full bg-gray-200 flex items-center justify-center text-gray-500">
          Loading...
        </div>
      )}
      <div className="mt-2">{data.name || <i>No name provided</i>}</div>
      <div
        className="mt-2 cursor-pointer hover:underline relative"
        title={data.contract?.address}
        onClick={handleCopy}
      >
        {copied ? "Copied!" : shortAddress || <i>No contract address</i>}
      </div>
      <div className="mt-2" title={data.tokenId}>
        Token ID: {shortTokenId}
      </div>
    </div>
  );
}
Компонент NFTCARD получит предложение данных. Предоставление данных будет содержать метаданные NFT (изображение, имя, идентификатор токена и адрес контракта).
Состояние ImageUrl удерживает окончательный URL -адрес изображения для отображения NFT и скопированных треков, если адрес контракта был недавно скопирован в буфер обмена.
Функция ResulveImageUrl () сначала пытается использовать data.image.originalurl или data.image.cachedurl в качестве изображения NFT. Если они отсутствуют, он получает метаданные из data.tokenuri, заменяя любые ipfs: // url на браузерhttps://ipfs.io/ipfs/формат. Затем он извлекает поле изображения из метаданных и устанавливает его как конечное изображение для отображения.
Функция Handlecopy копирует адрес контракта в буфер обмена пользователя и настраивает скопированную в True.
Создание модального компонента
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
interface ModalProps {
    isOpen: boolean;
    onClose: () => void;
    title: string;
    children: React.ReactNode;
    type?: "error" | "success" | "warning" | "info";
}
export default function Modal({
    isOpen,
    onClose,
    title,
    children,
    type = "error"
}: ModalProps) {
    const modalRef = useRef<HTMLDivElement>(null);
    // Handle escape key
    useEffect(() => {
        const handleEscape = (e: KeyboardEvent) => {
            if (e.key === "Escape") {
                onClose();
            }
        };
        if (isOpen) {
            document.addEventListener("keydown", handleEscape);
            // Prevent body scroll when modal is open
            document.body.style.overflow = "hidden";
        }
        return () => {
            document.removeEventListener("keydown", handleEscape);
            document.body.style.overflow = "unset";
        };
    }, [isOpen, onClose]);
    // Focus management
    useEffect(() => {
        if (isOpen && modalRef.current) {
            modalRef.current.focus();
        }
    }, [isOpen]);
    if (!isOpen) return null;
    const getIconAndColors = () => {
        switch (type) {
            case "error":
                return {
                    icon: "❌",
                    bgColor: "bg-red-50",
                    borderColor: "border-red-200",
                    iconBg: "bg-red-100",
                    titleColor: "text-red-800",
                    textColor: "text-red-700"
                };
            case "success":
                return {
                    icon: "✅",
                    bgColor: "bg-green-50",
                    borderColor: "border-green-200",
                    iconBg: "bg-green-100",
                    titleColor: "text-green-800",
                    textColor: "text-green-700"
                };
            case "warning":
                return {
                    icon: "⚠️",
                    bgColor: "bg-yellow-50",
                    borderColor: "border-yellow-200",
                    iconBg: "bg-yellow-100",
                    titleColor: "text-yellow-800",
                    textColor: "text-yellow-700"
                };
            default:
                return {
                    icon: "ℹ️",
                    bgColor: "bg-blue-50",
                    borderColor: "border-blue-200",
                    iconBg: "bg-blue-100",
                    titleColor: "text-blue-800",
                    textColor: "text-blue-700"
                };
        }
    };
    const { icon, bgColor, borderColor, iconBg, titleColor, textColor } = getIconAndColors();
    return (
        <div
            className="fixed inset-0 z-50 overflow-y-auto"
            aria-labelledby="modal-title"
            role="dialog"
            aria-modal="true"
        >
            {/* Backdrop */}
            <div
                className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
                onClick={onClose}
            ></div>
            {/* Modal */}
            <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
                <div
                    ref={modalRef}
                    tabIndex={-1}
                    className={`relative transform overflow-hidden rounded-lg ${bgColor} ${borderColor} border-2 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6`}
                >
                    <div>
                        <div className={`mx-auto flex h-12 w-12 items-center justify-center rounded-full ${iconBg}`}>
                            <span className="text-2xl">{icon}</span>
                        </div>
                        <div className="mt-3 text-center sm:mt-5">
                            <h3
                                className={`text-lg font-medium leading-6 ${titleColor}`}
                                id="modal-title"
                            >
                                {title}
                            </h3>
                            <div className={`mt-2 ${textColor}`}>
                                {children}
                            </div>
                        </div>
                    </div>
                    <div className="mt-5 sm:mt-6">
                        <button
                            type="button"
                            className="inline-flex w-full justify-center rounded-md bg-gray-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
                            onClick={onClose}
                        >
                            Close
                        </button>
                    </div>
                </div>
            </div>
        </div>
    );
}
Модальный компонент отображает диалоги «наложение» для нашего пользовательского интерфейса, получая реквизиты, которые контролируют его поведение и внешний вид.
Логические параметры Isopen и Onclose показывают/прячутся и закрывают модал соответственно. Параметр детей представляет содержимое для отображения внутри модала.
Наконец, параметр необязательного типа, который по умолчанию по умолчанию указывает тип модального отображения.
Шаг 7 - Обновите компоненты
Импортируйте компонент nftcard.tsx и modal.tsx в файл page.tsx:
import { useState } from "react";
import NFTCard from "./components/NFTCard";
import Modal from "./components/Modal";
Чуть ниже нового импорта компонентов, скопируйте и вставьте код ниже в файл page.tsx:
interface ImageData {
  originalUrl?: string;
  cachedUrl?: string;
}
interface ContractData {
  address: string;
}
interface NFTData {
  image?: ImageData;
  tokenUri?: string | { raw?: string };
  contract: ContractData;
  tokenId: string;
  name?: string;
}
interface ApiResponse {
  data: {
    ownedNfts: NFTData[];
  };
}
interface ApiError {
  error: string;
}
interface ModalState {
  isOpen: boolean;
  title: string;
  message: string;
  type: "error" | "success" | "warning" | "info";
}
В приведенном выше обновлении мы вводим интерфейсы для определения типа данных объектов.
- The ImageData Interface sets the structure of an NFT image to have optional originalUrl and cachedUrl fields, whereas the ContractData interface has one required field.
- Интерфейс NFTDATA определяет информацию об одном NFT, а ApiResponse обозначает успешную структуру вызова API.
- Интерфейсы Apierror и Modalstate определяют ответы по ошибкам API и структуру модального компонента соответственно.
На следующем шаге мы добавим переменные состояния и определим функции для управления нашем пользовательским интерфейсом.
Добавьте код ниже в свой компонент page.tsx.
const [address, setAddress] = useState<string>("");
  const [data, setData] = useState<NFTData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [hasSearched, setHasSearched] = useState<boolean>(false);
  const [modal, setModal] = useState<ModalState>({
    isOpen: false,
    title: "",
    message: "",
    type: "error",
  });
  const showModal = (
    title: string,
    message: string,
    type: ModalState["type"] = "error"
  ) => {
    setModal({
      isOpen: true,
      title,
      message,
      type,
    });
  };
  const closeModal = () => {
    setModal((prev) => ({ ...prev, isOpen: false }));
  };
  const getNfts = async (): Promise<void> => {
    if (!address.trim()) {
      showModal(
        "Invalid Input",
        "Please enter a wallet address before searching.",
        "warning"
      );
      return;
    }
    setLoading(true);
    setHasSearched(true);
    try {
      const response = await fetch(`./api/getnfts?wallet=${address}`);
      if (!response.ok) {
        try {
          const errorData: ApiError = await response.json();
          const errorMessage =
            errorData.error || `HTTP error! status: ${response.status}`;
          switch (response.status) {
            case 400:
              showModal(
                "Invalid Request",
                errorMessage ||
                  "The wallet address format is invalid. Please check and try again."
              );
              break;
            case 401:
              showModal(
                "Authentication Error",
                errorMessage ||
                  "API authentication failed. Please contact support."
              );
              break;
            case 408:
              showModal(
                "Request Timeout",
                errorMessage ||
                  "The request took too long to complete. Please try again."
              );
              break;
            case 429:
              showModal(
                "Rate Limit Exceeded",
                errorMessage ||
                  "Too many requests. Please wait a moment and try again."
              );
              break;
            case 500:
              showModal(
                "Server Error",
                errorMessage || "Internal server error. Please try again later."
              );
              break;
            default:
              showModal(
                "Request Failed",
                errorMessage ||
                  `Unexpected error occurred (${response.status}). Please try again.`
              );
          }
        } catch {
          showModal(
            "Network Error",
            `Failed to fetch NFTs. Server responded with status ${response.status}.`
          );
        }
        setData([]);
        return;
      }
      const responseData: ApiResponse = await response.json();
      console.log(responseData);
      setData(responseData.data.ownedNfts);
    } catch (error) {
      console.error("Error fetching NFTs:", error);
      if (error instanceof TypeError && error.message.includes("fetch")) {
        showModal(
          "Connection Error",
          "Unable to connect to the server. Please check your internet connection and try again."
        );
      } else {
        showModal(
          "Unexpected Error",
          "An unexpected error occurred while fetching NFTs. Please try again."
        );
      }
      setData([]);
    } finally {
      setLoading(false);
    }
  };
  const handleAddressChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ): void => {
    setAddress(e.target.value);
  };
  const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => {
    if (e.key === "Enter") {
      getNfts();
    }
  };
  const EmptyState = () => (
    <div className="flex flex-col items-center justify-center py-20 px-5">
      <div className="text-6xl mb-6">🖼️</div>
      <h2 className="text-2xl font-semibold text-gray-600 mb-3">
        No NFTs Found
      </h2>
      <p className="text-gray-500 text-center max-w-md mb-6">
        We couldn't find any NFTs for this wallet address. This could mean:
      </p>
      <ul className="text-gray-500 text-sm space-y-2 mb-8">
        <li>• The wallet doesn't own any NFTs</li>
        <li>• The address might be incorrect</li>
        <li>• The NFTs might not be indexed yet</li>
      </ul>
      <button
        onClick={() => {
          setAddress("");
          setHasSearched(false);
          setData([]);
        }}
        className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-all"
      >
        Try Another Address
      </button>
    </div>
  );
  const LoadingState = () => (
    <div className="flex flex-col items-center justify-center py-20">
      <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mb-4"></div>
      <p className="text-gray-600">Loading NFTs...</p>
    </div>
  );

Проверка, наше приложение все еще выглядит великолепно, мы ничего не сломали!
На следующем шаге мы должны установить значение входного тега на адрес кошелька. Обновите тег ввода, вставив этот код:
value={address}
onChange={handleAddressChange}
onKeyDown={handleKeyPress}
disabled={loading}
Обновите тег кнопки:
onClick={getNfts}
disabled={loading}
In the button tag, add the loading variable, which disables the button and and shows the loading message while fetching the NFTs.
<button
    className="px-5 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-all cursor-pointer"
    onClick={getNfts}
    disabled={loading}
>
    {loading ? "Loading..." : "Get NFTs"}
</button>
Наконец, обновите нашу область контента, добавив NFTCard и Modal Components:
{/* Content Area */}
        {loading ? (
          <LoadingState />
        ) : hasSearched && data.length === 0 ? (
          <EmptyState />
        ) : data.length > 0 ? (
          <>
            <div className="text-center text-gray-600">
              Found {data.length} NFT{data.length !== 1 ? 's' : ''}
            </div>
            <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-5">
              {data.map((nft: NFTData) => (
                <NFTCard
                  key={`${nft.contract.address}-${nft.tokenId}`}
                  data={nft}
                />
              ))}
            </div>
          </>
        ) : (
          <div className="text-center text-gray-500 py-20">
            Enter a wallet address above to explore NFTs
          </div>
        )}
      </div>
        
      {/* Modal */}
      <Modal
        isOpen={modal.isOpen}
        onClose={closeModal}
        title={modal.title}
        type={modal.type}
      >
        <p className="text-sm">{modal.message}</p>
      </Modal>
    </div>
Последний взгляд
Это то, на что следует выглядеть наша страница.
"use client";
import { useState } from "react";
import NFTCard from "./components/NFTCard";
import NFTCard from "./components/NFTCard";import NFTCard from "./components/NFTCard";
import Modal from "./components/Modal";
interface ImageData {
  originalUrl?: string;
  cachedUrl?: string;
}
interface ContractData {
  address: string;
}
interface NFTData {
  image?: ImageData;
  tokenUri?: string | { raw?: string };
  contract: ContractData;
  tokenId: string;
  name?: string;
}
interface ApiResponse {
  data: {
    ownedNfts: NFTData[];
  };
}
interface ApiError {
  error: string;
}
interface ModalState {
  isOpen: boolean;
  title: string;
  message: string;
  type: "error" | "success" | "warning" | "info";
}
export default function Home() {
  const [address, setAddress] = useState<string>("");
  const [data, setData] = useState<NFTData[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const [hasSearched, setHasSearched] = useState<boolean>(false);
  const [modal, setModal] = useState<ModalState>({
    isOpen: false,
    title: "",
    message: "",
    type: "error",
  });
  const showModal = (
    title: string,
    message: string,
    type: ModalState["type"] = "error"
  ) => {
    setModal({
      isOpen: true,
      title,
      message,
      type,
    });
  };
  const closeModal = () => {
    setModal((prev) => ({ ...prev, isOpen: false }));
  };
  const getNfts = async (): Promise<void> => {
    if (!address.trim()) {
      showModal(
        "Invalid Input",
        "Please enter a wallet address before searching.",
        "warning"
      );
      return;
    }
    setLoading(true);
    setHasSearched(true);
    try {
      const response = await fetch(`./api/getnfts?wallet=${address}`);
      if (!response.ok) {
        try {
          const errorData: ApiError = await response.json();
          const errorMessage =
            errorData.error || `HTTP error! status: ${response.status}`;
          switch (response.status) {
            case 400:
              showModal(
                "Invalid Request",
                errorMessage ||
                  "The wallet address format is invalid. Please check and try again."
              );
              break;
            case 401:
              showModal(
                "Authentication Error",
                errorMessage ||
                  "API authentication failed. Please contact support."
              );
              break;
            case 408:
              showModal(
                "Request Timeout",
                errorMessage ||
                  "The request took too long to complete. Please try again."
              );
              break;
            case 429:
              showModal(
                "Rate Limit Exceeded",
                errorMessage ||
                  "Too many requests. Please wait a moment and try again."
              );
              break;
            case 500:
              showModal(
                "Server Error",
                errorMessage || "Internal server error. Please try again later."
              );
              break;
            default:
              showModal(
                "Request Failed",
                errorMessage ||
                  `Unexpected error occurred (${response.status}). Please try again.`
              );
          }
        } catch {
          showModal(
            "Network Error",
            `Failed to fetch NFTs. Server responded with status ${response.status}.`
          );
        }
        setData([]);
        return;
      }
      const responseData: ApiResponse = await response.json();
      console.log(responseData);
      setData(responseData.data.ownedNfts);
    } catch (error) {
      console.error("Error fetching NFTs:", error);
      if (error instanceof TypeError && error.message.includes("fetch")) {
        showModal(
          "Connection Error",
          "Unable to connect to the server. Please check your internet connection and try again."
        );
      } else {
        showModal(
          "Unexpected Error",
          "An unexpected error occurred while fetching NFTs. Please try again."
        );
      }
      setData([]);
    } finally {
      setLoading(false);
    }
  };
  const handleAddressChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ): void => {
    setAddress(e.target.value);
  };
  const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => {
    if (e.key === "Enter") {
      getNfts();
    }
  };
  const EmptyState = () => (
    <div className="flex flex-col items-center justify-center py-20 px-5">
      <div className="text-6xl mb-6">🖼️</div>
      <h2 className="text-2xl font-semibold text-gray-600 mb-3">
        No NFTs Found
      </h2>
      <p className="text-gray-500 text-center max-w-md mb-6">
        We couldn't find any NFTs for this wallet address. This could mean:
      </p>
      <ul className="text-gray-500 text-sm space-y-2 mb-8">
        <li>• The wallet doesn't own any NFTs</li>
        <li>• The address might be incorrect</li>
        <li>• The NFTs might not be indexed yet</li>
      </ul>
      <button
        onClick={() => {
          setAddress("");
          setHasSearched(false);
          setData([]);
        }}
        className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-all"
      >
        Try Another Address
      </button>
    </div>
  );
  const LoadingState = () => (
    <div className="flex flex-col items-center justify-center py-20">
      <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mb-4"></div>
      <p className="text-gray-600">Loading NFTs...</p>
    </div>
  );
  return (
    <div className="h-full mt-20 p-5">
      <div className="flex flex-col gap-10">
        <div className="flex items-center justify-center">
          <h1 className="text-3xl font-bold text-gray-800">NFT EXPLORER</h1>
        </div>
        <div className="flex space-x-5 items-center justify-center">
          <input
            type="text"
            placeholder="Enter your wallet address"
            className="px-5 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
            value={address}
            onChange={handleAddressChange}
            onKeyDown={handleKeyPress}
            disabled={loading}
          />
          <button
            className="px-5 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-all cursor-pointer disabled:bg-gray-400 disabled:cursor-not-allowed"
            onClick={getNfts}
            disabled={loading}
          >
            {loading ? "Loading..." : "Get NFTs"}
          </button>
        </div>
        {/* Content Area */}
        {loading ? (
          <LoadingState />
        ) : hasSearched && data.length === 0 ? (
          <EmptyState />
        ) : data.length > 0 ? (
          <>
            <div className="text-center text-gray-600">
              Found {data.length} NFT{data.length !== 1 ? "s" : ""}
            </div>
            <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-5">
              {data.map((nft: NFTData) => (
                <NFTCard
                  key={`${nft.contract.address}-${nft.tokenId}`}
                  data={nft}
                />
              ))}
            </div>
          </>
        ) : (
          <div className="text-center text-gray-500 py-20">
            Enter a wallet address above to explore NFTs
          </div>
        )}
      </div>
      {/* Modal */}
      <Modal
        isOpen={modal.isOpen}
        onClose={closeModal}
        title={modal.title}
        type={modal.type}
      >
        <p className="text-sm">{modal.message}</p>
      </Modal>
    </div>
  );
}

Шаг 8 - Реконфигурирование файла следующего.config.mjs
Теперь, когда мы построили пользовательский интерфейс, нам придется перенастроить файл следующего.config.mjs. Он расположен в корневом каталоге нашего проекта.
Скопируйте и вставьте этот файл в свой файл stearm.config.mjs:
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "ipfs.io",
      },
      {
        protocol: "https",
        hostname: "nft-cdn.alchemy.com",
      },
      {
        protocol: "https",
        hostname: "res.cloudinary.com",
      },
      {
        protocol: "https",
        hostname: "i.seadn.io", 
      },
      {
        protocol: "https",
        hostname: "www.troublemaker.fun", 
      },
      {
        protocol: "https",
        hostname: "y.at", 
      },
      {
        protocol: "https",
        hostname: "**", 
      },
    ],
  },
};
export default nextConfig;
В приведенном выше коде мы инструктируем NextJS отображать изображения с этих веб -сайтов. Этот шаг очень важен, так как изображения NFT размещаются на разных внешних URL -адресах, кроме вашего сервера NextJS.
Поздравляю, ваш Ethereum NFT Explorer полностью функционален. Это то, что он отображает при поиске, используя этот адрес: 0x7928DC4ED0BF505274F62F65FA4776FFF2C2207E.

Это знаменует собой конец нашего пути построения Ethereum NFT Explorer с использованием NextJS. Вы можете найти полный исходный кодздесьПолем
За пределами этого
Чтобы записать, вы научились:
- Подключитесь к блокчейну Ethereum
- Принесите данные NFT по кошельку или адресу сбора
- Отображать NFTS и их метаданные в базовом пользовательском интерфейсе с использованием NextJS
Как вызов, я бы посоветовал вам построить NFT Explorer:
- На нескольких поддерживаемых цепях
- Использование переключателей для переключения между цепями.
Ciao
Оригинал
