跳转到内容

NFT Mint 网站

NFT Mint 网站

本页内容正在整理中,欢迎贡献

概述

本教程构建一个完整的 NFT 铸造网站:用户连接钱包后可以铸造 NFT,支持公开铸造和白名单铸造,带有实时状态显示。涵盖 ERC-721 合约 + Next.js 前端的全栈开发。

主要内容

项目结构

nft-mint-site/
├── contracts/
│ └── MyNFT.sol # ERC-721 合约
├── scripts/
│ └── deploy.ts # 部署脚本
├── test/
│ └── MyNFT.test.ts
└── frontend/ # Next.js 前端
├── app/
│ ├── page.tsx # 首页(Mint 页面)
│ └── providers.tsx
└── components/
└── MintSection.tsx

合约实现(简化版)

contracts/MyNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
contract MyNFT is ERC721, Ownable {
uint256 public constant MAX_SUPPLY = 1000;
uint256 public constant MINT_PRICE = 0.01 ether;
uint256 public constant MAX_PER_WALLET = 3;
uint256 private _nextTokenId;
string private _baseTokenURI;
bool public publicMintOpen;
mapping(address => uint256) public mintedPerWallet;
event Minted(address indexed to, uint256 indexed tokenId);
constructor(address initialOwner)
ERC721("My NFT Collection", "MNFT")
Ownable(initialOwner)
{}
function publicMint(uint256 quantity) external payable {
require(publicMintOpen, "Public mint not open");
require(quantity > 0 && quantity <= MAX_PER_WALLET, "Invalid quantity");
require(mintedPerWallet[msg.sender] + quantity <= MAX_PER_WALLET, "Exceeds per-wallet limit");
require(_nextTokenId + quantity <= MAX_SUPPLY, "Sold out");
require(msg.value >= MINT_PRICE * quantity, "Insufficient ETH");
mintedPerWallet[msg.sender] += quantity;
for (uint256 i = 0; i < quantity; ) {
uint256 tokenId = _nextTokenId++;
_safeMint(msg.sender, tokenId);
emit Minted(msg.sender, tokenId);
unchecked { ++i; }
}
// 退还多余 ETH
if (msg.value > MINT_PRICE * quantity) {
payable(msg.sender).transfer(msg.value - MINT_PRICE * quantity);
}
}
function setPublicMintOpen(bool open) external onlyOwner {
publicMintOpen = open;
}
function setBaseURI(string calldata baseURI) external onlyOwner {
_baseTokenURI = baseURI;
}
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
function totalMinted() external view returns (uint256) {
return _nextTokenId;
}
function _baseURI() internal view override returns (string memory) {
return _baseTokenURI;
}
}

前端实现(Next.js + wagmi)

components/MintSection.tsx
"use client";
import { useState } from "react";
import {
useAccount, useReadContracts, useWriteContract,
useWaitForTransactionReceipt
} from "wagmi";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { parseEther, formatEther } from "viem";
import { MY_NFT_ADDRESS, MY_NFT_ABI } from "@/lib/contracts";
export function MintSection() {
const { address, isConnected } = useAccount();
const [quantity, setQuantity] = useState(1);
// 读取合约状态
const { data, refetch } = useReadContracts({
contracts: [
{ address: MY_NFT_ADDRESS, abi: MY_NFT_ABI, functionName: "totalMinted" },
{ address: MY_NFT_ADDRESS, abi: MY_NFT_ABI, functionName: "MAX_SUPPLY" },
{ address: MY_NFT_ADDRESS, abi: MY_NFT_ABI, functionName: "MINT_PRICE" },
{ address: MY_NFT_ADDRESS, abi: MY_NFT_ABI, functionName: "publicMintOpen" },
{
address: MY_NFT_ADDRESS, abi: MY_NFT_ABI,
functionName: "mintedPerWallet",
args: address ? [address] : undefined,
query: { enabled: !!address },
},
],
});
const [totalMinted, maxSupply, mintPrice, publicMintOpen, mintedByUser] = data ?? [];
// 铸造
const { writeContract, data: txHash, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
onSuccess: () => refetch(),
});
const handleMint = () => {
writeContract({
address: MY_NFT_ADDRESS,
abi: MY_NFT_ABI,
functionName: "publicMint",
args: [BigInt(quantity)],
value: mintPrice?.result ? mintPrice.result * BigInt(quantity) : 0n,
});
};
const isSoldOut = totalMinted?.result >= maxSupply?.result;
const isMinting = isPending || isConfirming;
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-xl shadow">
<h1 className="text-2xl font-bold mb-4">My NFT Collection</h1>
{/* 铸造进度 */}
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-600">
<span>已铸造</span>
<span>{totalMinted?.result?.toString() ?? "..."} / {maxSupply?.result?.toString() ?? "..."}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-600 h-2 rounded-full"
style={{
width: `${((Number(totalMinted?.result ?? 0n) / Number(maxSupply?.result ?? 1n)) * 100)}%`
}}
/>
</div>
</div>
{/* 价格 */}
<p className="text-gray-600 mb-4">
铸造价格:{mintPrice?.result ? formatEther(mintPrice.result) : "..."} ETH /
</p>
{!isConnected ? (
<ConnectButton />
) : isSoldOut ? (
<button className="w-full bg-gray-400 text-white py-3 rounded-lg" disabled>
已售罄
</button>
) : !publicMintOpen?.result ? (
<button className="w-full bg-gray-400 text-white py-3 rounded-lg" disabled>
铸造尚未开放
</button>
) : (
<div>
{/* 数量选择 */}
<div className="flex items-center gap-3 mb-4">
<button onClick={() => setQuantity(Math.max(1, quantity - 1))} className="px-3 py-1 border rounded">-</button>
<span className="text-xl font-bold">{quantity}</span>
<button onClick={() => setQuantity(Math.min(3, quantity + 1))} className="px-3 py-1 border rounded">+</button>
<span className="text-sm text-gray-500">最多 3</span>
</div>
<button
onClick={handleMint}
disabled={isMinting}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white py-3 rounded-lg font-bold"
>
{isPending ? "确认中..." : isConfirming ? "上链中..." : `铸造 ${quantity} 个 NFT`}
</button>
{isSuccess && (
<p className="mt-3 text-green-600 text-center">🎉 铸造成功!</p>
)}
</div>
)}
</div>
);
}

运行项目

Terminal window
# 1. 安装依赖
npm install
# 2. 运行本地测试
npx hardhat test
# 3. 部署到 Sepolia
npx hardhat run scripts/deploy.ts --network sepolia
# 4. 启动前端
cd frontend && npm run dev

深入阅读