From 974f353b33c84bfdc04be4f80a86f104e9ef8b9a Mon Sep 17 00:00:00 2001 From: Jay989810 Date: Wed, 29 Jul 2026 10:24:02 +0100 Subject: [PATCH 1/3] frontend OpenRiver NFT --- .../lesson3/openriver/src/components/Card.tsx | 212 +++++++-- .../openriver/src/components/Cards.tsx | 138 +++++- .../openriver/src/components/Header.tsx | 69 ++- .../src/components/NFTActionModals.tsx | 407 ++++++++++++++++++ .../lesson3/openriver/src/hooks/useNFTs.ts | 147 +++++++ .../openriver/src/pages/dashboard/index.tsx | 60 ++- .../lesson3/openriver/src/pages/index.tsx | 67 ++- .../lesson3/openriver/src/pages/layout.tsx | 43 +- .../openriver/src/pages/list/index.tsx | 181 ++++++-- .../openriver/src/pages/mint/index.tsx | 197 +++++++-- .../openriver/src/pages/myNFT/index.tsx | 104 +++-- .../lesson3/openriver/src/styles/globals.css | 79 +++- .../lesson3/openriver/src/utils/nft.ts | 94 ++++ 13 files changed, 1596 insertions(+), 202 deletions(-) create mode 100644 frontend-lectures/lesson3/openriver/src/components/NFTActionModals.tsx create mode 100644 frontend-lectures/lesson3/openriver/src/hooks/useNFTs.ts create mode 100644 frontend-lectures/lesson3/openriver/src/utils/nft.ts diff --git a/frontend-lectures/lesson3/openriver/src/components/Card.tsx b/frontend-lectures/lesson3/openriver/src/components/Card.tsx index 0c24e68..0f64806 100644 --- a/frontend-lectures/lesson3/openriver/src/components/Card.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/Card.tsx @@ -1,56 +1,178 @@ -import React from 'react' -import { useReadContract } from 'wagmi' +import React, { useState } from 'react'; +import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'; import { openriverAbi, openriverAddress } from '../contracts'; +import { NFTItemData, truncateAddress } from '../utils/nft'; +import { ListModal, TransferModal, ApproveModal } from './NFTActionModals'; -const Card = ({ tokenId }: { tokenId: bigint }) => { - const { data: onchainNFT } = useReadContract({ - abi: openriverAbi, - address: openriverAddress, - functionName: 'tokenURI', - args: [tokenId], - }) - - const normalizedImageSrc = React.useMemo(() => { - if (typeof onchainNFT !== 'string' || !onchainNFT) { - return null - } +// Props passed into Card component +interface CardProps { + nft: NFTItemData; + onRefresh: () => void; +} - if (onchainNFT.startsWith('ipfs://')) { - return `https://ipfs.io/ipfs/${onchainNFT.replace('ipfs://', '')}` - } +// Single NFT Card Component +export const Card: React.FC = ({ nft, onRefresh }) => { + // Get current logged-in user wallet address + const { address } = useAccount(); + + // State variables for controlling modal popups (true = open, false = closed) + const [showList, setShowList] = useState(false); + const [showTransfer, setShowTransfer] = useState(false); + const [showApprove, setShowApprove] = useState(false); - return onchainNFT - }, [onchainNFT]) + // Wagmi hooks to handle Quick Buy transaction + const { writeContract: buyContract, data: buyHash, isPending: isBuyPending } = useWriteContract(); + const { isLoading: isBuyConfirming, isSuccess: isBuySuccess } = useWaitForTransactionReceipt({ hash: buyHash }); + + // Wagmi hooks to handle Quick Delist transaction + const { writeContract: delistContract, data: delistHash, isPending: isDelistPending } = useWriteContract(); + const { isLoading: isDelistConfirming, isSuccess: isDelistSuccess } = useWaitForTransactionReceipt({ hash: delistHash }); + + // Refresh page data when buy or delist transaction succeeds + React.useEffect(() => { + if (isBuySuccess || isDelistSuccess) { + onRefresh(); + } + }, [isBuySuccess, isDelistSuccess, onRefresh]); + // Check if logged-in user owns this NFT + const isOwner = Boolean(address && nft.owner.toLowerCase() === address.toLowerCase()); - const {data: marketData} = useReadContract({ - abi: openriverAbi, - address: openriverAddress, - functionName: "marketplace", - args: [BigInt(tokenId)] - }) + // Function called when user clicks "Buy Now" button + const handleQuickBuy = () => { + buyContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'purchase', + args: [nft.tokenId], + value: nft.price, + }); + }; - console.log(marketData?.[0]) + // Function called when owner clicks "Remove Listing" button + const handleQuickDelist = () => { + delistContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'removeFromMarketplace', + args: [nft.tokenId], + }); + }; return ( -
-
- {normalizedImageSrc ? ( - This one na NFT - ) : ( -

NFT image unavailable

- )} -
-
-

#29

-

200 ETH

+ <> + {/* Outer Card Container */} +
+
+ {/* NFT Image Container */} +
+ {nft.name} + {/* Status Badge */} +
+ {nft.isListed ? ( + + For Sale + + ) : ( + + Not Listed + + )} +
+ {/* Token ID Badge */} +
+ + #{nft.tokenId.toString()} + +
+
+ + {/* NFT Name & Owner Information */} +
+

+ {nft.name} +

+
+ Owner: + + {isOwner ? 'You' : truncateAddress(nft.owner, 4)} + +
+
+
+ + {/* Price & Action Buttons Area */} +
+
+ + {nft.isListed ? 'Price' : 'Status'} + + + {nft.isListed ? `${nft.priceFormatted} ETH` : 'Not Listed'} + +
+ + {/* Conditional Action Buttons */} +
+ {isOwner ? ( + nft.isListed ? ( + + ) : ( + + ) + ) : nft.isListed ? ( + + ) : ( +
+ Not Available +
+ )} +
+
-
- ) -} -export default Card \ No newline at end of file + {/* Modal Dialog Popups */} + setShowList(false)} + nft={nft} + onSuccess={onRefresh} + /> + setShowTransfer(false)} + nft={nft} + onSuccess={onRefresh} + /> + setShowApprove(false)} + nft={nft} + onSuccess={onRefresh} + /> + + ); +}; + +export default Card; \ No newline at end of file diff --git a/frontend-lectures/lesson3/openriver/src/components/Cards.tsx b/frontend-lectures/lesson3/openriver/src/components/Cards.tsx index c16fe41..d90685d 100644 --- a/frontend-lectures/lesson3/openriver/src/components/Cards.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/Cards.tsx @@ -1,25 +1,129 @@ -import { useReadContract } from "wagmi"; -import Card from "./Card"; -import { openriverAbi, openriverAddress } from "../contracts"; +import React, { useState, useMemo } from 'react'; +import Card from './Card'; +import { useNFTs } from '../hooks/useNFTs'; +import { useAccount } from 'wagmi'; -export const Cards = ({ nftNum }: { nftNum?: bigint }) => { - - const {data: tokenIds} = useReadContract({ - abi: openriverAbi, - address: openriverAddress, - functionName: "tokenIds", - }) +// Component to render a list/grid of NFT cards with tab filtering and search +export const Cards = ({ initialFilter }: { initialFilter?: 'all' | 'listed' | 'my' }) => { + // Get all NFTs and loading state from custom hook + const { nfts, loading, refresh } = useNFTs(); + const { address } = useAccount(); + // Active filter tab state ('all', 'listed', or 'my') + const [activeTab, setActiveTab] = useState<'all' | 'listed' | 'my'>(initialFilter || 'all'); + // Search input state string + const [searchTerm, setSearchTerm] = useState(''); + // Filter NFTs list based on selected tab and search term + const filteredNFTs = useMemo(() => { + return nfts.filter((nft) => { + // 1. Tab filter check + if (activeTab === 'listed' && !nft.isListed) return false; + if (activeTab === 'my') { + if (!address) return false; + if (nft.owner.toLowerCase() !== address.toLowerCase()) return false; + } + // 2. Search term check + if (searchTerm.trim() !== '') { + const query = searchTerm.toLowerCase(); + const matchesName = nft.name.toLowerCase().includes(query); + const matchesTokenId = nft.tokenId.toString().includes(query); + const matchesOwner = nft.owner.toLowerCase().includes(query); + if (!matchesName && !matchesTokenId && !matchesOwner) return false; + } + return true; + }); + }, [nfts, activeTab, searchTerm, address]); - return ( -
- {Array.from({ length: Number(nftNum) }, (_, i) => i + 1).map((index) => ( - - ))} +
+ {/* Top Filter and Search Bar */} +
+ {/* Tab Buttons */} +
+ + + +
+ + {/* Search Input Box & Refresh Button */} +
+ setSearchTerm(e.target.value)} + className="simple-input px-3 py-2 rounded-lg text-xs w-full sm:w-64" + /> + +
+
+ + {/* Loading indicator while fetching NFTs */} + {loading && ( +
+ Loading NFTs from smart contract... Please wait. +
+ )} + + {/* Render NFT Grid when data is loaded */} + {!loading && filteredNFTs.length > 0 && ( +
+ {/* Map through array of NFTs and render a Card component for each */} + {filteredNFTs.map((nft) => ( + + ))} +
+ )} + + {/* Empty State when no NFTs match filter */} + {!loading && filteredNFTs.length === 0 && ( +
+

+

No NFTs Found

+

+ {activeTab === 'my' + ? address + ? 'You do not own any NFTs in this store yet. Try minting one!' + : 'Please connect your wallet to view your owned NFTs.' + : activeTab === 'listed' + ? 'There are currently no items put up for sale.' + : 'No items match your search.'} +

+
+ )}
- ) -} + ); +}; diff --git a/frontend-lectures/lesson3/openriver/src/components/Header.tsx b/frontend-lectures/lesson3/openriver/src/components/Header.tsx index 162d66b..cbd6627 100644 --- a/frontend-lectures/lesson3/openriver/src/components/Header.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/Header.tsx @@ -1,21 +1,60 @@ -import { ConnectButton } from '@rainbow-me/rainbowkit' -import Link from 'next/link' -import React from 'react' +import { ConnectButton } from '@rainbow-me/rainbowkit'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React from 'react'; +// Header component for top navigation bar const Header = () => { + // Get current page URL path using Next.js router + const router = useRouter(); + + // Navigation menu items with simple English names + const navLinks = [ + { name: 'Home', path: '/' }, + { name: 'Mint NFT', path: '/mint' }, + { name: 'Sell NFT', path: '/list' }, + { name: 'My NFTs', path: '/myNFT' }, + ]; + return ( -
-
Open River
-
- Home - Minting - Listing - MyNFTs +
+
+ + {/* App Title & Logo */} + + 🌊 + OpenRiver NFT Store + + + {/* Navigation Links */} + + {/* Wallet Connect Button */} +
+ +
-
-
- ) -} + + ); +}; -export default Header \ No newline at end of file +export default Header; \ No newline at end of file diff --git a/frontend-lectures/lesson3/openriver/src/components/NFTActionModals.tsx b/frontend-lectures/lesson3/openriver/src/components/NFTActionModals.tsx new file mode 100644 index 0000000..36cca09 --- /dev/null +++ b/frontend-lectures/lesson3/openriver/src/components/NFTActionModals.tsx @@ -0,0 +1,407 @@ +import React, { useState, useEffect } from 'react'; +import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi'; +import { parseEther } from 'viem'; +import { openriverAbi, openriverAddress } from '../contracts'; +import { NFTItemData, truncateAddress, resolveIPFS } from '../utils/nft'; + +// Common props interface for modal windows +interface ModalProps { + isOpen: boolean; + onClose: () => void; + nft: NFTItemData | null; + onSuccess?: () => void; +} + +// 1. LISTING MODAL: Lets owner put NFT for sale with a specified ETH price +export const ListModal: React.FC = ({ isOpen, onClose, nft, onSuccess }) => { + // Price state input by user + const [priceEth, setPriceEth] = useState(''); + + // Wagmi hooks to trigger 'listOnMarketplace' transaction + const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + // Close modal when transaction succeeds + useEffect(() => { + if (isSuccess) { + onSuccess?.(); + onClose(); + setPriceEth(''); + } + }, [isSuccess, onSuccess, onClose]); + + if (!isOpen || !nft) return null; + + // Handle form submit for listing + const handleList = () => { + if (!priceEth || isNaN(Number(priceEth)) || Number(priceEth) <= 0) return; + try { + const priceWei = parseEther(priceEth); // Convert ETH string to Wei BigInt + writeContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'listOnMarketplace', + args: [nft.tokenId, priceWei], + }); + } catch (e) { + console.error(e); + } + }; + + return ( +
+
+ + +

Put NFT For Sale

+

Set a price in ETH to sell {nft.name}.

+ +
+ + setPriceEth(e.target.value)} + className="simple-input w-full px-3 py-2 rounded text-sm" + /> +
+ + {writeError && ( +
+ Error: {writeError.message.slice(0, 100)}... +
+ )} + +
+ + +
+
+
+ ); +}; + + +// 2. TRANSFER MODAL: Lets owner transfer NFT to another wallet address +export const TransferModal: React.FC = ({ isOpen, onClose, nft, onSuccess }) => { + const { address } = useAccount(); + const [recipient, setRecipient] = useState(''); + + // Wagmi hooks to trigger 'safeTransferFrom' transaction + const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + useEffect(() => { + if (isSuccess) { + onSuccess?.(); + onClose(); + setRecipient(''); + } + }, [isSuccess, onSuccess, onClose]); + + if (!isOpen || !nft) return null; + + // Handle transfer submit + const handleTransfer = () => { + if (!address || !recipient.startsWith('0x') || recipient.length !== 42) return; + writeContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'safeTransferFrom', + args: [address as `0x${string}`, recipient as `0x${string}`, nft.tokenId], + }); + }; + + return ( +
+
+ + +

Send NFT to Another Address

+

Enter recipient wallet address below.

+ +
+ + setRecipient(e.target.value)} + className="simple-input w-full px-3 py-2 rounded text-xs font-mono" + /> +
+ + {writeError && ( +
+ Error: {writeError.message.slice(0, 100)}... +
+ )} + +
+ + +
+
+
+ ); +}; + + +// 3. APPROVAL MODAL: Sets approval for marketplace operator to manage NFT +export const ApproveModal: React.FC = ({ isOpen, onClose, nft, onSuccess }) => { + const [operator, setOperator] = useState(openriverAddress); + const [isForAll, setIsForAll] = useState(true); + + // Wagmi hooks to trigger approval transaction + const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + useEffect(() => { + if (isSuccess) { + onSuccess?.(); + onClose(); + } + }, [isSuccess, onSuccess, onClose]); + + if (!isOpen || !nft) return null; + + // Handle set approval submit + const handleApprove = () => { + if (!operator.startsWith('0x')) return; + if (isForAll) { + writeContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'setApprovalForAll', + args: [operator as `0x${string}`, true], + }); + } else { + writeContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'approve', + args: [operator as `0x${string}`, nft.tokenId], + }); + } + }; + + return ( +
+
+ + +

Approve Operator

+

Grant permissions to contract address to handle your NFT.

+ +
+
+ + setOperator(e.target.value)} + className="simple-input w-full px-3 py-2 rounded text-xs font-mono" + /> +
+ +
+ setIsForAll(e.target.checked)} + className="w-4 h-4" + /> + +
+
+ + {writeError && ( +
+ Error: {writeError.message.slice(0, 100)}... +
+ )} + +
+ + +
+
+
+ ); +}; + + +// 4. DETAIL MODAL: Displays full details of an NFT with buy, list, and transfer action buttons +export const DetailModal: React.FC<{ + isOpen: boolean; + onClose: () => void; + nft: NFTItemData | null; + onOpenList: () => void; + onOpenTransfer: () => void; + onOpenApprove: () => void; + onRefresh: () => void; +}> = ({ isOpen, onClose, nft, onOpenList, onOpenTransfer, onOpenApprove, onRefresh }) => { + const { address } = useAccount(); + + // Wagmi hooks to handle direct Buy from detail modal + const { writeContract: buyContract, data: buyHash, isPending: isBuyPending } = useWriteContract(); + const { isLoading: isBuyConfirming, isSuccess: isBuySuccess } = useWaitForTransactionReceipt({ hash: buyHash }); + + // Wagmi hooks to handle direct Delist from detail modal + const { writeContract: delistContract, data: delistHash, isPending: isDelistPending } = useWriteContract(); + const { isLoading: isDelistConfirming, isSuccess: isDelistSuccess } = useWaitForTransactionReceipt({ hash: delistHash }); + + useEffect(() => { + if (isBuySuccess || isDelistSuccess) { + onRefresh(); + onClose(); + } + }, [isBuySuccess, isDelistSuccess, onRefresh, onClose]); + + if (!isOpen || !nft) return null; + + const isOwner = Boolean(address && nft.owner.toLowerCase() === address.toLowerCase()); + + // Function to buy NFT + const handleBuy = () => { + buyContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'purchase', + args: [nft.tokenId], + value: nft.price, + }); + }; + + // Function to remove listing + const handleDelist = () => { + delistContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'removeFromMarketplace', + args: [nft.tokenId], + }); + }; + + return ( +
+
+ + +
+ {/* NFT Image */} +
+
+ {nft.name} +
+
+ Price +

+ {nft.isListed ? `${nft.priceFormatted} ETH` : 'Not Listed'} +

+
+
+ + {/* NFT Details */} +
+
+

{nft.name}

+

{nft.description || 'No description provided.'}

+ +
+
+ Token ID: + #{nft.tokenId.toString()} +
+
+ Owner: + {isOwner ? 'You' : truncateAddress(nft.owner, 4)} +
+
+ Creator Royalty: + {nft.royalty.toString()}% +
+
+
+ + {/* Action Buttons */} +
+ {isOwner ? ( +
+ {nft.isListed ? ( + + ) : ( + + )} + +
+ + +
+
+ ) : ( + nft.isListed ? ( + + ) : ( +
+ This item is not listed for sale. +
+ ) + )} +
+
+
+
+
+ ); +}; diff --git a/frontend-lectures/lesson3/openriver/src/hooks/useNFTs.ts b/frontend-lectures/lesson3/openriver/src/hooks/useNFTs.ts new file mode 100644 index 0000000..c04e437 --- /dev/null +++ b/frontend-lectures/lesson3/openriver/src/hooks/useNFTs.ts @@ -0,0 +1,147 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useReadContract, useReadContracts, useAccount } from 'wagmi'; +import { openriverAbi, openriverAddress } from '../contracts'; +import { fetchNFTMetadata, getFallbackImage, NFTItemData, formatPrice } from '../utils/nft'; + +// Custom React Hook to get all NFTs from the smart contract +export function useNFTs() { + // Get connected user address from Wagmi + const { address } = useAccount(); + + // State to store list of NFTs + const [nfts, setNfts] = useState([]); + // State to track if data is loading + const [loading, setLoading] = useState(true); + + // 1. Read total number of minted NFTs from smart contract ('tokenIds' function) + const { data: totalTokens, refetch: refetchTotal } = useReadContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'tokenIds', + }); + + // Convert total count to a regular number + const count = totalTokens ? Number(totalTokens) : 0; + + // 2. Build array of contract read calls for token IDs 1 up to total count + const calls = []; + for (let i = 1; i <= count; i++) { + const tid = BigInt(i); + // Call tokenURI(i) to get metadata URL + calls.push({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'tokenURI', + args: [tid], + }); + // Call ownerOf(i) to get owner address + calls.push({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'ownerOf', + args: [tid], + }); + // Call marketplace(i) to get price and listing status + calls.push({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'marketplace', + args: [tid], + }); + } + + // Execute all read calls at once + const { data: rawData, refetch: refetchDetails, isLoading: detailsLoading } = useReadContracts({ + contracts: calls as any, + query: { + enabled: count > 0, + } + }); + + // Function to process raw data and set the NFTs state + const loadAllNFTs = useCallback(async () => { + if (!count || !rawData || rawData.length === 0) { + setNfts([]); + setLoading(false); + return; + } + + setLoading(true); + const items: NFTItemData[] = []; + + // Loop through each token ID and format data + for (let i = 1; i <= count; i++) { + const idx = (i - 1) * 3; + const uriResult = rawData[idx]?.result as string | undefined; + const ownerResult = rawData[idx + 1]?.result as string | undefined; + const marketResult = rawData[idx + 2]?.result as [boolean, bigint, string, bigint] | undefined; + + const tokenId = BigInt(i); + const tokenURI = uriResult || ''; + const owner = ownerResult || '0x0000000000000000000000000000000000000000'; + + const isListed = Boolean(marketResult?.[0]); + const price = marketResult?.[1] ? BigInt(marketResult[1].toString()) : BigInt(0); + const publisher = marketResult?.[2] || owner; + const royalty = marketResult?.[3] ? BigInt(marketResult[3].toString()) : BigInt(0); + + // Fetch metadata image and name + let metadata = null; + let imageSrc = getFallbackImage(tokenId); + let name = `NFT Item #${tokenId.toString()}`; + let description = `OpenRiver NFT #${tokenId.toString()}`; + + if (tokenURI) { + metadata = await fetchNFTMetadata(tokenURI); + if (metadata) { + if (metadata.image) imageSrc = metadata.image; + if (metadata.name) name = metadata.name; + if (metadata.description) description = metadata.description; + } else if (tokenURI.startsWith('http://') || tokenURI.startsWith('https://') || tokenURI.startsWith('ipfs://') || tokenURI.startsWith('data:image')) { + imageSrc = tokenURI.startsWith('ipfs://') ? `https://ipfs.io/ipfs/${tokenURI.replace('ipfs://', '')}` : tokenURI; + } + } + + // Add formatted NFT object to items list + items.push({ + tokenId, + tokenURI, + owner, + isListed, + price, + priceFormatted: formatPrice(price), + publisher, + royalty, + metadata, + imageSrc, + name, + description, + }); + } + + // Show newest NFTs first + setNfts(items.reverse()); + setLoading(false); + }, [count, rawData]); + + // useEffect runs when component loads or dependencies change + useEffect(() => { + loadAllNFTs(); + }, [loadAllNFTs]); + + // Function to reload NFT data manually + const refresh = useCallback(() => { + refetchTotal(); + refetchDetails(); + }, [refetchTotal, refetchDetails]); + + // Return values for components to use + return { + nfts, + totalTokens: count, + loading: loading || detailsLoading, + refresh, + userNFTs: nfts.filter((item) => address && item.owner.toLowerCase() === address.toLowerCase()), + listedNFTs: nfts.filter((item) => item.isListed), + }; +} diff --git a/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx b/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx index bbe41a6..b4e7c50 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx @@ -1,35 +1,51 @@ -import { useWriteContract } from "wagmi" +import { useWriteContract } from "wagmi"; import { openriverAbi, openriverAddress } from "../../contracts"; import { useState } from "react"; - +// Simple Dashboard Component const Dashboard = () => { + // Wagmi hook to handle mint contract call const { writeContract: mintNFT } = useWriteContract(); + + // Simple state variables for form inputs const [tokenUrI, setTokenUrI] = useState(""); const [royalty, setRoyalty] = useState(0); + // Mint NFT function handler const handleMintNFT = async () => { - mintNFT( - { - abi: openriverAbi, - address: openriverAddress, - functionName: "newItem", - args: [ - tokenUrI, - royalty - ] - } - ) - } + mintNFT({ + abi: openriverAbi, + address: openriverAddress, + functionName: "newItem", + args: [ + tokenUrI, + royalty + ] + }); + }; + return ( -
-
- setTokenUrI(e.target.value)} /> - setRoyalty(Number(e.target.value))} /> - +
+

Dashboard - Mint NFT

+
+ setTokenUrI(e.target.value)} + className="simple-input w-full px-3 py-2 text-xs rounded" + /> + setRoyalty(Number(e.target.value))} + className="simple-input w-full px-3 py-2 text-xs rounded" + /> +
- ) -} + ); +}; -export default Dashboard \ No newline at end of file +export default Dashboard; \ No newline at end of file diff --git a/frontend-lectures/lesson3/openriver/src/pages/index.tsx b/frontend-lectures/lesson3/openriver/src/pages/index.tsx index e158e7f..8c67070 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/index.tsx @@ -1,19 +1,68 @@ -import { ConnectButton } from '@rainbow-me/rainbowkit'; import type { NextPage } from 'next'; import Head from 'next/head'; -import styles from '../styles/Home.module.css'; -import Header from '../components/Header'; +import Link from 'next/link'; import { Cards } from '../components/Cards'; +import { useNFTs } from '../hooks/useNFTs'; +// Home Page Component const Home: NextPage = () => { + // Use custom hook to get total tokens count and list of items put for sale + const { totalTokens, listedNFTs, loading } = useNFTs(); + return ( -
+ <> + + OpenRiver | Simple NFT Store + + + +
+ {/* Banner Section */} +
+
+

+ Welcome to OpenRiver NFT Store 🌊 +

+ +

+ This is a simple marketplace where you can create (mint) new NFTs, list your NFTs for sale, and buy NFTs from other users. +

+ + {/* Quick Action Buttons */} +
+ + Mint New NFT + + + Sell Your NFT + +
+ + {/* Simple Stats Box */} +
+
+ Total Minted +

+ {loading ? '...' : totalTokens} +

+
+
+ Items For Sale +

+ {loading ? '...' : listedNFTs.length} +

+
+
+
+
-
- -
- -
+ {/* NFT Marketplace Grid Section */} +
+

Explore NFTs

+ +
+
+ ); }; diff --git a/frontend-lectures/lesson3/openriver/src/pages/layout.tsx b/frontend-lectures/lesson3/openriver/src/pages/layout.tsx index 96b65cd..b84f6d6 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/layout.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/layout.tsx @@ -1,16 +1,41 @@ -import type { ReactNode } from 'react' -import Header from '../components/Header' +import type { ReactNode } from 'react'; +import Header from '../components/Header'; +import Link from 'next/link'; +import { openriverAddress } from '../contracts'; +import { truncateAddress } from '../utils/nft'; type LayoutProps = { - children: ReactNode -} + children: ReactNode; +}; export default function DashboardLayout({ children }: LayoutProps) { return ( -
-
-
{children}
-
Footer
+
+
+ +
+ {children} +
+ +
- ) + ); } \ No newline at end of file diff --git a/frontend-lectures/lesson3/openriver/src/pages/list/index.tsx b/frontend-lectures/lesson3/openriver/src/pages/list/index.tsx index d0896b1..2ddc956 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/list/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/list/index.tsx @@ -1,35 +1,158 @@ -import { useState } from 'react' -import { useWriteContract } from 'wagmi'; +import type { NextPage } from 'next'; +import Head from 'next/head'; +import React, { useState, useEffect } from 'react'; +import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi'; +import { parseEther } from 'viem'; +import { useRouter } from 'next/router'; import { openriverAbi, openriverAddress } from '../../contracts'; +import { useNFTs } from '../../hooks/useNFTs'; - const index = () => { - const { writeContract: mintNFT } = useWriteContract(); - const [tokenId, setTokenId] = useState(0); - const [price, setPrice] = useState(0); - - const handleMintNFT = async () => { - mintNFT( - { - abi: openriverAbi, - address: openriverAddress, - functionName: "listOnMarketplace", - args: [ - tokenId, - price - ] - } - ) +// Sell/List NFT Page Component +const ListPage: NextPage = () => { + const router = useRouter(); + const { isConnected } = useAccount(); + const { userNFTs } = useNFTs(); + + // State inputs for Token ID and Price in ETH + const [tokenIdInput, setTokenIdInput] = useState(''); + const [priceEth, setPriceEth] = useState(''); + + // Wagmi hooks to run 'listOnMarketplace' function + const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + // Find selected NFT object from owned list if matches token ID + const selectedNFT = userNFTs.find((item) => item.tokenId.toString() === tokenIdInput); + + // Navigate to home page when item is successfully listed + useEffect(() => { + if (isSuccess) { + const timer = setTimeout(() => { + router.push('/'); + }, 2000); + return () => clearTimeout(timer); } - return ( -
-
- setTokenId(Number(e.target.value))} /> - setPrice(Number(e.target.value))} /> - -
+ }, [isSuccess, router]); + + // Handle form submit for listing NFT + const handleList = () => { + if (!tokenIdInput || !priceEth || isNaN(Number(priceEth)) || Number(priceEth) <= 0) return; + try { + const priceWei = parseEther(priceEth); // Convert ETH string input into Wei + writeContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'listOnMarketplace', + args: [BigInt(tokenIdInput), priceWei], + }); + } catch (e) { + console.error(e); + } + }; + + return ( + <> + + Sell NFT | OpenRiver + + +
+
+

Sell Your NFT

+

Put your NFT up for sale on the store

- ) -} +
+ {/* Select NFT from owned items */} +
+ + {userNFTs.length > 0 ? ( + + ) : ( +

+ No owned NFTs detected. You can enter Token ID manually below: +

+ )} + + setTokenIdInput(e.target.value)} + className="simple-input w-full px-3 py-2 rounded text-xs" + /> +
+ + {/* Selected NFT Preview */} + {selectedNFT && ( +
+ {selectedNFT.name} +
+

{selectedNFT.name}

+

ID: #{selectedNFT.tokenId.toString()}

+
+
+ )} + + {/* Price input */} +
+ + setPriceEth(e.target.value)} + className="simple-input w-full px-3 py-2 rounded text-xs" + /> +
+ + {/* Write Error */} + {writeError && ( +
+ Error: {writeError.message.slice(0, 100)}... +
+ )} + + {/* Success message */} + {isSuccess && ( +
+ 🎉 Success! NFT listed for sale. Redirecting to home... +
+ )} + + {/* Submit button */} + +
+
+ + ); +}; -export default index \ No newline at end of file +export default ListPage; \ No newline at end of file diff --git a/frontend-lectures/lesson3/openriver/src/pages/mint/index.tsx b/frontend-lectures/lesson3/openriver/src/pages/mint/index.tsx index ebd6ea6..8a514a3 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/mint/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/mint/index.tsx @@ -1,35 +1,174 @@ -import React, { useState } from 'react' -import { useWriteContract } from 'wagmi'; +import type { NextPage } from 'next'; +import Head from 'next/head'; +import React, { useState, useEffect } from 'react'; +import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi'; +import { useRouter } from 'next/router'; import { openriverAbi, openriverAddress } from '../../contracts'; - const index = () => { - const { writeContract: mintNFT } = useWriteContract(); - const [tokenUrI, setTokenUrI] = useState(""); - const [royalty, setRoyalty] = useState(0); - - const handleMintNFT = async () => { - mintNFT( - { - abi: openriverAbi, - address: openriverAddress, - functionName: "newItem", - args: [ - tokenUrI, - royalty - ] - } - ) +// Sample image preset URLs to make testing easy for beginners +const SAMPLE_PRESETS = [ + { + name: 'Cyberpunk Image', + uri: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?auto=format&fit=crop&w=600&q=80', + }, + { + name: 'Abstract Fluid', + uri: 'https://images.unsplash.com/photo-1579783902614-a3fb3927b675?auto=format&fit=crop&w=600&q=80', + }, + { + name: 'Cosmic Nebula', + uri: 'https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?auto=format&fit=crop&w=600&q=80', + }, +]; + +// Mint Page Component +const MintPage: NextPage = () => { + const router = useRouter(); + const { isConnected } = useAccount(); + + // State variable to store token image link entered in form + const [tokenURI, setTokenURI] = useState(SAMPLE_PRESETS[0].uri); + // State variable for creator royalty percentage + const [royalty, setRoyalty] = useState(5); + + // Wagmi hooks to run 'newItem' contract function on smart contract + const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + // When transaction succeeds, wait 2 seconds and navigate to My NFTs page + useEffect(() => { + if (isSuccess) { + const timer = setTimeout(() => { + router.push('/myNFT'); + }, 2000); + return () => clearTimeout(timer); } - return ( -
-
- setTokenUrI(e.target.value)} /> - setRoyalty(Number(e.target.value))} /> - -
+ }, [isSuccess, router]); + + // Function called when user clicks 'Mint NFT' button + const handleMint = () => { + if (!tokenURI) return; + writeContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'newItem', + args: [tokenURI, BigInt(royalty)], + }); + }; + + return ( + <> + + Mint NFT | OpenRiver + + +
+
+

Create New NFT

+

Fill in details below to mint your unique NFT on smart contract

- ) -} +
+ {/* Token URI input */} +
+ + setTokenURI(e.target.value)} + className="simple-input w-full px-3 py-2 rounded text-xs mb-2" + /> + + {/* Presets */} +
+ Quick Presets: +
+ {SAMPLE_PRESETS.map((preset, idx) => ( + + ))} +
+
+
+ + {/* Royalty input */} +
+
+ + {royalty}% +
+ setRoyalty(Number(e.target.value))} + className="w-full" + /> +

+ You will earn {royalty}% on secondary sales of this NFT. +

+
+ + {/* Image Preview Box */} +
+ Preview: +
+ {tokenURI ? ( + Preview + ) : ( +
+ No Image +
+ )} +
+
+ + {/* Write Error Notification */} + {writeError && ( +
+ Error: {writeError.message.slice(0, 100)}... +
+ )} + + {/* Success Notification */} + {isSuccess && ( +
+ 🎉 Success! NFT Minted. Redirecting to My NFTs... +
+ )} + + {/* Submit Button */} + +
+
+ + ); +}; -export default index \ No newline at end of file +export default MintPage; \ No newline at end of file diff --git a/frontend-lectures/lesson3/openriver/src/pages/myNFT/index.tsx b/frontend-lectures/lesson3/openriver/src/pages/myNFT/index.tsx index 3c1e0b9..07382f2 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/myNFT/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/myNFT/index.tsx @@ -1,37 +1,89 @@ -import { NextPage } from "next"; -import { Cards } from "../../components/Cards"; -import { useReadContract } from "wagmi"; -import { openriverAbi, openriverAddress } from "../../contracts"; -import { useEffect, useState } from "react"; +import type { NextPage } from 'next'; +import Head from 'next/head'; +import { useEffect } from 'react'; +import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract } from 'wagmi'; +import { Cards } from '../../components/Cards'; +import { useNFTs } from '../../hooks/useNFTs'; +import { openriverAbi, openriverAddress } from '../../contracts'; -const MyNFT: NextPage = () => { - const [nftsNum, setNftsNum] = useState() +// My Collection Page Component +const MyNFTPage: NextPage = () => { + const { address, isConnected } = useAccount(); + const { refresh } = useNFTs(); - const { data: nftmaxNum } = useReadContract({ - abi: openriverAbi, - address: openriverAddress, - functionName: "tokenIds", - }) as any + // Read smart contract to check if user has approved marketplace operator + const { data: isApprovedForAll, refetch: refetchApproval } = useReadContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'isApprovedForAll', + args: address ? [address as `0x${string}`, openriverAddress as `0x${string}`] : undefined, + query: { enabled: Boolean(address) }, + }); - useEffect(() => { - setNftsNum(nftmaxNum) - }, [nftmaxNum]) + // Wagmi hooks to update approval status + const { writeContract, data: hash, isPending } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); - useEffect(() => { - setNftsNum(nftmaxNum) - }, [nftmaxNum]) + useEffect(() => { + if (isSuccess) { + refetchApproval(); + refresh(); + } + }, [isSuccess, refetchApproval, refresh]); + // Function to toggle operator approval + const handleToggleApproval = () => { + if (!address) return; + writeContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'setApprovalForAll', + args: [openriverAddress, !isApprovedForAll], + }); + }; - return ( -
+ return ( + <> + + My Collection | OpenRiver + -

{nftsNum?.toString()}

-
- -
+
+ {/* User Info Header */} +
+
+

My NFT Collection

+

+ {address ? address : 'Please connect your wallet'} +

+
+ {/* Operator Approval Button */} + {isConnected && ( +
+ + Marketplace Approval: {isApprovedForAll ? '✓ Enabled' : 'Disabled'} + + +
+ )}
- ); + + {/* Collection Grid */} + +
+ + ); }; -export default MyNFT; \ No newline at end of file +export default MyNFTPage; \ No newline at end of file diff --git a/frontend-lectures/lesson3/openriver/src/styles/globals.css b/frontend-lectures/lesson3/openriver/src/styles/globals.css index a461c50..02c2730 100644 --- a/frontend-lectures/lesson3/openriver/src/styles/globals.css +++ b/frontend-lectures/lesson3/openriver/src/styles/globals.css @@ -1 +1,78 @@ -@import "tailwindcss"; \ No newline at end of file +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); +@import "tailwindcss"; + +/* Basic reset and color variables */ +:root { + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --bg-color: #0f172a; + --card-bg: #1e293b; + --border-color: #334155; +} + +body { + font-family: var(--font-sans); + background-color: var(--bg-color); + color: #f8fafc; + min-height: 100vh; +} + +/* Simple container panel style for cards and sections */ +.simple-card { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; +} + +/* Simple input field style */ +.simple-input { + background-color: #0f172a; + border: 1px solid #334155; + color: #ffffff; +} + +.simple-input:focus { + outline: none; + border-color: #38bdf8; +} + +/* Simple primary button style */ +.btn-primary { + background-color: #0284c7; + color: #ffffff; + font-weight: 600; + border-radius: 8px; + transition: background-color 0.2s; +} + +.btn-primary:hover { + background-color: #0369a1; +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Simple secondary button style */ +.btn-secondary { + background-color: #334155; + color: #ffffff; + font-weight: 600; + border-radius: 8px; +} + +.btn-secondary:hover { + background-color: #475569; +} + +/* Simple red danger button style */ +.btn-danger { + background-color: #dc2626; + color: #ffffff; + font-weight: 600; + border-radius: 8px; +} + +.btn-danger:hover { + background-color: #b91c1c; +} \ No newline at end of file diff --git a/frontend-lectures/lesson3/openriver/src/utils/nft.ts b/frontend-lectures/lesson3/openriver/src/utils/nft.ts new file mode 100644 index 0000000..307dad5 --- /dev/null +++ b/frontend-lectures/lesson3/openriver/src/utils/nft.ts @@ -0,0 +1,94 @@ +import { formatEther } from 'viem'; + +// Type definition for NFT metadata (information stored about an NFT like name, description, image) +export interface NFTMetadata { + name?: string; + description?: string; + image?: string; + attributes?: Array<{ trait_type: string; value: string | number }>; +} + +// Type definition for an NFT item in our app +export interface NFTItemData { + tokenId: bigint; + tokenURI: string; + owner: string; + isListed: boolean; + price: bigint; + priceFormatted: string; + publisher: string; + royalty: bigint; + metadata?: NFTMetadata | null; + imageSrc: string; + name: string; + description: string; +} + +// Helper function: Converts IPFS links (ipfs://...) to regular web links (https://ipfs.io/...) +export function resolveIPFS(url: string | undefined | null): string { + if (!url) return ''; + if (url.startsWith('ipfs://')) { + const cid = url.replace('ipfs://', ''); + return `https://ipfs.io/ipfs/${cid}`; + } + return url; +} + +// Helper function: Fetches NFT metadata JSON file from web or IPFS link +export async function fetchNFTMetadata(tokenURI: string): Promise { + if (!tokenURI) return null; + const resolved = resolveIPFS(tokenURI); + try { + if (resolved.startsWith('http://') || resolved.startsWith('https://')) { + const res = await fetch(resolved); + if (res.ok) { + const json = await res.json(); + return { + name: json.name || json.title, + description: json.description, + image: resolveIPFS(json.image || json.image_url), + attributes: json.attributes || [] + }; + } + } + } catch (e) { + console.warn(`Failed to fetch metadata for ${tokenURI}:`, e); + } + return null; +} + +// Helper function: Truncates long wallet addresses to look short (e.g. 0x1234...abcd) +export function truncateAddress(address: string, chars = 4): string { + if (!address) return ''; + return `${address.substring(0, chars + 2)}...${address.substring(address.length - chars)}`; +} + +// Helper function: Converts price from Wei (smallest ETH unit) to ETH decimal string +export function formatPrice(priceWei: bigint | undefined | null): string { + if (priceWei === undefined || priceWei === null) return '0'; + try { + return formatEther(priceWei); + } catch { + return '0'; + } +} + +// Helper function: Generates a colorful placeholder SVG image if an NFT has no image URL +export function getFallbackImage(tokenId: bigint): string { + const hues = [210, 260, 320, 180, 45, 140, 280, 15]; + const hue1 = hues[Number(tokenId % BigInt(hues.length))]; + const hue2 = (hue1 + 60) % 360; + const svg = ` + + + + + + + + + #${tokenId.toString()} + NFT ITEM + `; + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} From b54543e806adb259a7a4b935c253516565becdab Mon Sep 17 00:00:00 2001 From: Jay989810 Date: Wed, 29 Jul 2026 10:34:34 +0100 Subject: [PATCH 2/3] refactor: simplify OpenRiver codebase with beginner React comments and IPFS fallbacks --- .../lesson3/openriver/src/components/Card.tsx | 36 +++-- .../openriver/src/components/Cards.tsx | 51 ++++--- .../openriver/src/components/Header.tsx | 18 ++- .../src/components/NFTActionModals.tsx | 67 +++++---- .../lesson3/openriver/src/hooks/useNFTs.ts | 69 +++++---- .../openriver/src/pages/dashboard/index.tsx | 50 ++++--- .../lesson3/openriver/src/pages/index.tsx | 18 ++- .../lesson3/openriver/src/pages/layout.tsx | 10 +- .../openriver/src/pages/list/index.tsx | 30 ++-- .../openriver/src/pages/mint/index.tsx | 31 ++-- .../openriver/src/pages/myNFT/index.tsx | 16 +- .../lesson3/openriver/src/utils/nft.ts | 140 ++++++++++++++---- 12 files changed, 353 insertions(+), 183 deletions(-) diff --git a/frontend-lectures/lesson3/openriver/src/components/Card.tsx b/frontend-lectures/lesson3/openriver/src/components/Card.tsx index 0f64806..70f809a 100644 --- a/frontend-lectures/lesson3/openriver/src/components/Card.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/Card.tsx @@ -4,41 +4,45 @@ import { openriverAbi, openriverAddress } from '../contracts'; import { NFTItemData, truncateAddress } from '../utils/nft'; import { ListModal, TransferModal, ApproveModal } from './NFTActionModals'; -// Props passed into Card component +// ============================================================================== +// BEGINNER REACT LESSON: Component Props +// In React, components are custom HTML tags. `props` are arguments passed into +// the component tag (like ). +// ============================================================================== + interface CardProps { - nft: NFTItemData; - onRefresh: () => void; + nft: NFTItemData; // Single NFT item data object + onRefresh: () => void; // Function to reload NFT list when actions happen } -// Single NFT Card Component export const Card: React.FC = ({ nft, onRefresh }) => { - // Get current logged-in user wallet address + // 1. Get logged-in user wallet address from wagmi library const { address } = useAccount(); - // State variables for controlling modal popups (true = open, false = closed) + // 2. useState: Component memory to control popup modal visibility (true/false) const [showList, setShowList] = useState(false); const [showTransfer, setShowTransfer] = useState(false); const [showApprove, setShowApprove] = useState(false); - // Wagmi hooks to handle Quick Buy transaction + // 3. wagmi hooks to handle Quick Buy transaction const { writeContract: buyContract, data: buyHash, isPending: isBuyPending } = useWriteContract(); const { isLoading: isBuyConfirming, isSuccess: isBuySuccess } = useWaitForTransactionReceipt({ hash: buyHash }); - // Wagmi hooks to handle Quick Delist transaction + // 4. wagmi hooks to handle Quick Delist transaction const { writeContract: delistContract, data: delistHash, isPending: isDelistPending } = useWriteContract(); const { isLoading: isDelistConfirming, isSuccess: isDelistSuccess } = useWaitForTransactionReceipt({ hash: delistHash }); - // Refresh page data when buy or delist transaction succeeds + // 5. useEffect: Refresh page data automatically when transaction completes React.useEffect(() => { if (isBuySuccess || isDelistSuccess) { onRefresh(); } }, [isBuySuccess, isDelistSuccess, onRefresh]); - // Check if logged-in user owns this NFT + // Check if current user is owner of this NFT const isOwner = Boolean(address && nft.owner.toLowerCase() === address.toLowerCase()); - // Function called when user clicks "Buy Now" button + // Function called when user clicks "Buy Now" const handleQuickBuy = () => { buyContract({ abi: openriverAbi, @@ -49,7 +53,7 @@ export const Card: React.FC = ({ nft, onRefresh }) => { }); }; - // Function called when owner clicks "Remove Listing" button + // Function called when owner clicks "Remove Listing" const handleQuickDelist = () => { delistContract({ abi: openriverAbi, @@ -91,7 +95,7 @@ export const Card: React.FC = ({ nft, onRefresh }) => {
- {/* NFT Name & Owner Information */} + {/* NFT Name & Owner Info */}

{nft.name} @@ -105,7 +109,7 @@ export const Card: React.FC = ({ nft, onRefresh }) => {

- {/* Price & Action Buttons Area */} + {/* Price & Action Buttons */}
@@ -116,7 +120,7 @@ export const Card: React.FC = ({ nft, onRefresh }) => {
- {/* Conditional Action Buttons */} + {/* Action Buttons depending on owner or buyer status */}
{isOwner ? ( nft.isListed ? ( @@ -152,7 +156,7 @@ export const Card: React.FC = ({ nft, onRefresh }) => {
- {/* Modal Dialog Popups */} + {/* Modal Windows */} setShowList(false)} diff --git a/frontend-lectures/lesson3/openriver/src/components/Cards.tsx b/frontend-lectures/lesson3/openriver/src/components/Cards.tsx index d90685d..6a06eaf 100644 --- a/frontend-lectures/lesson3/openriver/src/components/Cards.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/Cards.tsx @@ -3,28 +3,36 @@ import Card from './Card'; import { useNFTs } from '../hooks/useNFTs'; import { useAccount } from 'wagmi'; -// Component to render a list/grid of NFT cards with tab filtering and search +// ============================================================================== +// BEGINNER REACT LESSON: Rendering Lists & Filters +// In React, we use `.map()` to loop through an array of items and render a +// component (like ) for each item. +// `useMemo` is an optimization that recalculates the filtered list only when +// `nfts`, `activeTab`, or `searchTerm` changes! +// ============================================================================== + export const Cards = ({ initialFilter }: { initialFilter?: 'all' | 'listed' | 'my' }) => { - // Get all NFTs and loading state from custom hook + // 1. Load NFT data and refresh function from our custom hook const { nfts, loading, refresh } = useNFTs(); const { address } = useAccount(); - // Active filter tab state ('all', 'listed', or 'my') + // 2. useState: Component memory to track selected filter tab ('all', 'listed', or 'my') const [activeTab, setActiveTab] = useState<'all' | 'listed' | 'my'>(initialFilter || 'all'); - // Search input state string + + // 3. useState: Component memory to store search input text typed by user const [searchTerm, setSearchTerm] = useState(''); - // Filter NFTs list based on selected tab and search term + // 4. Filter NFTs array according to active tab and search input text const filteredNFTs = useMemo(() => { return nfts.filter((nft) => { - // 1. Tab filter check + // Tab Filter Check if (activeTab === 'listed' && !nft.isListed) return false; if (activeTab === 'my') { if (!address) return false; if (nft.owner.toLowerCase() !== address.toLowerCase()) return false; } - // 2. Search term check + // Search Bar Filter Check if (searchTerm.trim() !== '') { const query = searchTerm.toLowerCase(); const matchesName = nft.name.toLowerCase().includes(query); @@ -39,40 +47,43 @@ export const Cards = ({ initialFilter }: { initialFilter?: 'all' | 'listed' | 'm return (
- {/* Top Filter and Search Bar */} + {/* Top Bar: Tabs & Search Input */}
- {/* Tab Buttons */} + {/* Filter Tabs */}
- {/* Search Input Box & Refresh Button */} + {/* Search Input & Refresh Button */}
- {/* Loading indicator while fetching NFTs */} + {/* Loading Message */} {loading && (
Loading NFTs from smart contract... Please wait.
)} - {/* Render NFT Grid when data is loaded */} + {/* Grid of NFT Cards */} {!loading && filteredNFTs.length > 0 && (
- {/* Map through array of NFTs and render a Card component for each */} {filteredNFTs.map((nft) => ( ))}
)} - {/* Empty State when no NFTs match filter */} + {/* Empty State message when 0 NFTs match filter */} {!loading && filteredNFTs.length === 0 && (
-

No NFTs Found

{activeTab === 'my' diff --git a/frontend-lectures/lesson3/openriver/src/components/Header.tsx b/frontend-lectures/lesson3/openriver/src/components/Header.tsx index cbd6627..6a29b48 100644 --- a/frontend-lectures/lesson3/openriver/src/components/Header.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/Header.tsx @@ -3,12 +3,18 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import React from 'react'; -// Header component for top navigation bar +// ============================================================================== +// BEGINNER REACT LESSON: Navigation Bar & Routing +// In Next.js + React: +// - `useRouter()` gives us the current page URL (e.g. '/' or '/mint'). +// - `` lets users navigate between pages instantly without reloading. +// ============================================================================== + const Header = () => { - // Get current page URL path using Next.js router + // Get current page route path const router = useRouter(); - // Navigation menu items with simple English names + // Navigation menu items array const navLinks = [ { name: 'Home', path: '/' }, { name: 'Mint NFT', path: '/mint' }, @@ -28,9 +34,9 @@ const Header = () => { {/* Navigation Links */}

- {/* Wallet Connect Button */} + {/* Wallet Connect Button (RainbowKit) */}
diff --git a/frontend-lectures/lesson3/openriver/src/components/NFTActionModals.tsx b/frontend-lectures/lesson3/openriver/src/components/NFTActionModals.tsx index 36cca09..6bc3b46 100644 --- a/frontend-lectures/lesson3/openriver/src/components/NFTActionModals.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/NFTActionModals.tsx @@ -2,26 +2,33 @@ import React, { useState, useEffect } from 'react'; import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi'; import { parseEther } from 'viem'; import { openriverAbi, openriverAddress } from '../contracts'; -import { NFTItemData, truncateAddress, resolveIPFS } from '../utils/nft'; +import { NFTItemData, truncateAddress } from '../utils/nft'; + +// ============================================================================== +// BEGINNER REACT LESSON: Modal Windows & Component Props +// In React, a "Modal" is a popup window that sits over the page content. +// Props are parameters passed into components (like function arguments). +// ============================================================================== -// Common props interface for modal windows interface ModalProps { - isOpen: boolean; - onClose: () => void; - nft: NFTItemData | null; - onSuccess?: () => void; + isOpen: boolean; // True = show modal popup, False = hide modal + onClose: () => void; // Function to call when user clicks 'X' or 'Cancel' + nft: NFTItemData | null; // The NFT object selected by user + onSuccess?: () => void; // Optional callback to refresh page when transaction finishes } -// 1. LISTING MODAL: Lets owner put NFT for sale with a specified ETH price +// ============================================================================== +// 1. LISTING MODAL: Allows NFT owner to put item for sale on marketplace +// ============================================================================== export const ListModal: React.FC = ({ isOpen, onClose, nft, onSuccess }) => { - // Price state input by user + // useState: Memory for input field where user types the ETH price const [priceEth, setPriceEth] = useState(''); - - // Wagmi hooks to trigger 'listOnMarketplace' transaction + + // wagmi hooks for writing to smart contract ('listOnMarketplace' function) const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); - // Close modal when transaction succeeds + // useEffect: Runs when transaction succeeds -> closes modal and resets form useEffect(() => { if (isSuccess) { onSuccess?.(); @@ -30,13 +37,15 @@ export const ListModal: React.FC = ({ isOpen, onClose, nft, onSucces } }, [isSuccess, onSuccess, onClose]); + // If modal is closed or no NFT is selected, render nothing (null) if (!isOpen || !nft) return null; - // Handle form submit for listing + // Called when user clicks "Confirm Listing" button const handleList = () => { if (!priceEth || isNaN(Number(priceEth)) || Number(priceEth) <= 0) return; try { - const priceWei = parseEther(priceEth); // Convert ETH string to Wei BigInt + // Convert price from ETH decimal string to Wei BigInt number + const priceWei = parseEther(priceEth); writeContract({ abi: openriverAbi, address: openriverAddress, @@ -90,12 +99,14 @@ export const ListModal: React.FC = ({ isOpen, onClose, nft, onSucces }; -// 2. TRANSFER MODAL: Lets owner transfer NFT to another wallet address +// ============================================================================== +// 2. TRANSFER MODAL: Allows owner to send NFT to another crypto wallet address +// ============================================================================== export const TransferModal: React.FC = ({ isOpen, onClose, nft, onSuccess }) => { const { address } = useAccount(); const [recipient, setRecipient] = useState(''); - - // Wagmi hooks to trigger 'safeTransferFrom' transaction + + // wagmi hooks to trigger 'safeTransferFrom' smart contract function const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); @@ -109,7 +120,6 @@ export const TransferModal: React.FC = ({ isOpen, onClose, nft, onSu if (!isOpen || !nft) return null; - // Handle transfer submit const handleTransfer = () => { if (!address || !recipient.startsWith('0x') || recipient.length !== 42) return; writeContract({ @@ -161,12 +171,14 @@ export const TransferModal: React.FC = ({ isOpen, onClose, nft, onSu }; -// 3. APPROVAL MODAL: Sets approval for marketplace operator to manage NFT +// ============================================================================== +// 3. APPROVAL MODAL: Grants marketplace permission to move NFT on owner's behalf +// ============================================================================== export const ApproveModal: React.FC = ({ isOpen, onClose, nft, onSuccess }) => { const [operator, setOperator] = useState(openriverAddress); const [isForAll, setIsForAll] = useState(true); - // Wagmi hooks to trigger approval transaction + // wagmi hooks to trigger 'setApprovalForAll' or 'approve' function const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); @@ -179,7 +191,6 @@ export const ApproveModal: React.FC = ({ isOpen, onClose, nft, onSuc if (!isOpen || !nft) return null; - // Handle set approval submit const handleApprove = () => { if (!operator.startsWith('0x')) return; if (isForAll) { @@ -254,7 +265,9 @@ export const ApproveModal: React.FC = ({ isOpen, onClose, nft, onSuc }; -// 4. DETAIL MODAL: Displays full details of an NFT with buy, list, and transfer action buttons +// ============================================================================== +// 4. DETAIL MODAL: Popup showing complete NFT image, price, creator, and actions +// ============================================================================== export const DetailModal: React.FC<{ isOpen: boolean; onClose: () => void; @@ -266,11 +279,11 @@ export const DetailModal: React.FC<{ }> = ({ isOpen, onClose, nft, onOpenList, onOpenTransfer, onOpenApprove, onRefresh }) => { const { address } = useAccount(); - // Wagmi hooks to handle direct Buy from detail modal + // wagmi hooks to buy NFT const { writeContract: buyContract, data: buyHash, isPending: isBuyPending } = useWriteContract(); const { isLoading: isBuyConfirming, isSuccess: isBuySuccess } = useWaitForTransactionReceipt({ hash: buyHash }); - // Wagmi hooks to handle direct Delist from detail modal + // wagmi hooks to delist NFT const { writeContract: delistContract, data: delistHash, isPending: isDelistPending } = useWriteContract(); const { isLoading: isDelistConfirming, isSuccess: isDelistSuccess } = useWaitForTransactionReceipt({ hash: delistHash }); @@ -285,7 +298,6 @@ export const DetailModal: React.FC<{ const isOwner = Boolean(address && nft.owner.toLowerCase() === address.toLowerCase()); - // Function to buy NFT const handleBuy = () => { buyContract({ abi: openriverAbi, @@ -296,7 +308,6 @@ export const DetailModal: React.FC<{ }); }; - // Function to remove listing const handleDelist = () => { delistContract({ abi: openriverAbi, @@ -312,7 +323,7 @@ export const DetailModal: React.FC<{
- {/* NFT Image */} + {/* Left Column: NFT Image */}
{nft.name} @@ -325,7 +336,7 @@ export const DetailModal: React.FC<{
- {/* NFT Details */} + {/* Right Column: NFT Details & Buttons */}

{nft.name}

@@ -347,7 +358,7 @@ export const DetailModal: React.FC<{
- {/* Action Buttons */} + {/* Action Buttons based on owner status */}
{isOwner ? (
diff --git a/frontend-lectures/lesson3/openriver/src/hooks/useNFTs.ts b/frontend-lectures/lesson3/openriver/src/hooks/useNFTs.ts index c04e437..66d77f1 100644 --- a/frontend-lectures/lesson3/openriver/src/hooks/useNFTs.ts +++ b/frontend-lectures/lesson3/openriver/src/hooks/useNFTs.ts @@ -3,45 +3,52 @@ import { useReadContract, useReadContracts, useAccount } from 'wagmi'; import { openriverAbi, openriverAddress } from '../contracts'; import { fetchNFTMetadata, getFallbackImage, NFTItemData, formatPrice } from '../utils/nft'; -// Custom React Hook to get all NFTs from the smart contract +// ============================================================================== +// BEGINNER REACT LESSON: Custom React Hooks +// A "Custom Hook" in React is a reusable function whose name starts with "use". +// It packages state variables (useState) and side effects (useEffect) so any +// page or component in our app can call `useNFTs()` to load NFT data easily! +// ============================================================================== + export function useNFTs() { - // Get connected user address from Wagmi + // 1. Get current logged-in user's wallet address from wagmi library hook const { address } = useAccount(); - // State to store list of NFTs + // 2. useState: Component memory to store the list of NFTs const [nfts, setNfts] = useState([]); - // State to track if data is loading + + // 3. useState: Component memory to track if data is still loading const [loading, setLoading] = useState(true); - // 1. Read total number of minted NFTs from smart contract ('tokenIds' function) + // 4. READ CONTRACT: Get total number of NFTs minted so far ('tokenIds' function) const { data: totalTokens, refetch: refetchTotal } = useReadContract({ abi: openriverAbi, address: openriverAddress, functionName: 'tokenIds', }); - // Convert total count to a regular number + // Convert contract BigInt count to regular JavaScript number (e.g. 5) const count = totalTokens ? Number(totalTokens) : 0; - // 2. Build array of contract read calls for token IDs 1 up to total count + // 5. Build array of smart contract calls to fetch details for every token (1 to count) const calls = []; for (let i = 1; i <= count; i++) { const tid = BigInt(i); - // Call tokenURI(i) to get metadata URL + // Contract call 1: Get tokenURI (image/metadata link) calls.push({ abi: openriverAbi, address: openriverAddress, functionName: 'tokenURI', args: [tid], }); - // Call ownerOf(i) to get owner address + // Contract call 2: Get owner address calls.push({ abi: openriverAbi, address: openriverAddress, functionName: 'ownerOf', args: [tid], }); - // Call marketplace(i) to get price and listing status + // Contract call 3: Get marketplace details (isListed, price, publisher, royalty) calls.push({ abi: openriverAbi, address: openriverAddress, @@ -50,7 +57,7 @@ export function useNFTs() { }); } - // Execute all read calls at once + // 6. READ MULTIPLE CONTRACTS AT ONCE: Execute all read calls in a single network request const { data: rawData, refetch: refetchDetails, isLoading: detailsLoading } = useReadContracts({ contracts: calls as any, query: { @@ -58,8 +65,9 @@ export function useNFTs() { } }); - // Function to process raw data and set the NFTs state + // 7. Process raw smart contract data and update React state const loadAllNFTs = useCallback(async () => { + // If no tokens exist yet, clear state and finish loading if (!count || !rawData || rawData.length === 0) { setNfts([]); setLoading(false); @@ -69,7 +77,7 @@ export function useNFTs() { setLoading(true); const items: NFTItemData[] = []; - // Loop through each token ID and format data + // Loop through each NFT ID from 1 to count for (let i = 1; i <= count; i++) { const idx = (i - 1) * 3; const uriResult = rawData[idx]?.result as string | undefined; @@ -85,24 +93,33 @@ export function useNFTs() { const publisher = marketResult?.[2] || owner; const royalty = marketResult?.[3] ? BigInt(marketResult[3].toString()) : BigInt(0); - // Fetch metadata image and name + // Default values if metadata or IPFS is missing or fails let metadata = null; let imageSrc = getFallbackImage(tokenId); let name = `NFT Item #${tokenId.toString()}`; let description = `OpenRiver NFT #${tokenId.toString()}`; + // If tokenURI exists, try fetching metadata if (tokenURI) { metadata = await fetchNFTMetadata(tokenURI); if (metadata) { if (metadata.image) imageSrc = metadata.image; if (metadata.name) name = metadata.name; if (metadata.description) description = metadata.description; - } else if (tokenURI.startsWith('http://') || tokenURI.startsWith('https://') || tokenURI.startsWith('ipfs://') || tokenURI.startsWith('data:image')) { - imageSrc = tokenURI.startsWith('ipfs://') ? `https://ipfs.io/ipfs/${tokenURI.replace('ipfs://', '')}` : tokenURI; + } else if ( + tokenURI.startsWith('http://') || + tokenURI.startsWith('https://') || + tokenURI.startsWith('ipfs://') || + tokenURI.startsWith('data:image') + ) { + // If metadata fetch failed, use tokenURI directly as image if it's a web/IPFS link + imageSrc = tokenURI.startsWith('ipfs://') + ? `https://ipfs.io/ipfs/${tokenURI.replace('ipfs://', '')}` + : tokenURI; } } - // Add formatted NFT object to items list + // Add formatted NFT item to array items.push({ tokenId, tokenURI, @@ -119,29 +136,31 @@ export function useNFTs() { }); } - // Show newest NFTs first + // Sort newest NFTs first (highest token ID at top) setNfts(items.reverse()); setLoading(false); }, [count, rawData]); - // useEffect runs when component loads or dependencies change + // 8. useEffect: Automatically runs loadAllNFTs when data changes useEffect(() => { loadAllNFTs(); }, [loadAllNFTs]); - // Function to reload NFT data manually + // 9. Helper function to manually refresh contract data const refresh = useCallback(() => { refetchTotal(); refetchDetails(); }, [refetchTotal, refetchDetails]); - // Return values for components to use + // 10. Return values for React components to use return { - nfts, - totalTokens: count, - loading: loading || detailsLoading, - refresh, + nfts, // Array of all NFTs + totalTokens: count, // Total number of NFTs + loading: loading || detailsLoading, // Is page loading? + refresh, // Function to reload data + // Filtered list: NFTs owned by connected wallet userNFTs: nfts.filter((item) => address && item.owner.toLowerCase() === address.toLowerCase()), + // Filtered list: NFTs currently listed for sale listedNFTs: nfts.filter((item) => item.isListed), }; } diff --git a/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx b/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx index b4e7c50..93ca4d8 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx @@ -2,16 +2,20 @@ import { useWriteContract } from "wagmi"; import { openriverAbi, openriverAddress } from "../../contracts"; import { useState } from "react"; -// Simple Dashboard Component +// ============================================================================== +// BEGINNER REACT LESSON: Simple Form Dashboard +// Demonstrates how to take user input from text fields and trigger a contract write. +// ============================================================================== + const Dashboard = () => { - // Wagmi hook to handle mint contract call + // 1. wagmi hook for minting NFT const { writeContract: mintNFT } = useWriteContract(); - // Simple state variables for form inputs + // 2. useState: Input fields state memory const [tokenUrI, setTokenUrI] = useState(""); const [royalty, setRoyalty] = useState(0); - // Mint NFT function handler + // 3. Mint button click handler const handleMintNFT = async () => { mintNFT({ abi: openriverAbi, @@ -26,20 +30,32 @@ const Dashboard = () => { return (
-

Dashboard - Mint NFT

+

Dashboard - Quick Mint

- setTokenUrI(e.target.value)} - className="simple-input w-full px-3 py-2 text-xs rounded" - /> - setRoyalty(Number(e.target.value))} - className="simple-input w-full px-3 py-2 text-xs rounded" - /> +
+ + setTokenUrI(e.target.value)} + className="simple-input w-full px-3 py-2 text-xs rounded" + /> +
+ +
+ + setRoyalty(Number(e.target.value))} + className="simple-input w-full px-3 py-2 text-xs rounded" + /> +
+ diff --git a/frontend-lectures/lesson3/openriver/src/pages/index.tsx b/frontend-lectures/lesson3/openriver/src/pages/index.tsx index 8c67070..19c8f15 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/index.tsx @@ -4,20 +4,26 @@ import Link from 'next/link'; import { Cards } from '../components/Cards'; import { useNFTs } from '../hooks/useNFTs'; -// Home Page Component +// ============================================================================== +// BEGINNER REACT LESSON: Home Page Component +// In Next.js, files inside the `pages/` directory automatically become web pages! +// `index.tsx` is the root URL of your website (http://localhost:3000/). +// ============================================================================== + const Home: NextPage = () => { - // Use custom hook to get total tokens count and list of items put for sale + // Call custom hook to get total number of tokens and list of NFTs for sale const { totalTokens, listedNFTs, loading } = useNFTs(); return ( <> + {/* HTML Head: Sets tab title in user browser */} OpenRiver | Simple NFT Store
- {/* Banner Section */} + {/* Top Hero Banner Section */}

@@ -28,7 +34,7 @@ const Home: NextPage = () => { This is a simple marketplace where you can create (mint) new NFTs, list your NFTs for sale, and buy NFTs from other users.

- {/* Quick Action Buttons */} + {/* Navigation Buttons to Mint and List pages */}
Mint New NFT @@ -38,7 +44,7 @@ const Home: NextPage = () => {
- {/* Simple Stats Box */} + {/* Live Marketplace Statistics */}
Total Minted @@ -56,7 +62,7 @@ const Home: NextPage = () => {

- {/* NFT Marketplace Grid Section */} + {/* NFT Cards Grid Section */}

Explore NFTs

diff --git a/frontend-lectures/lesson3/openriver/src/pages/layout.tsx b/frontend-lectures/lesson3/openriver/src/pages/layout.tsx index b84f6d6..e383e9f 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/layout.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/layout.tsx @@ -1,9 +1,14 @@ import type { ReactNode } from 'react'; import Header from '../components/Header'; -import Link from 'next/link'; import { openriverAddress } from '../contracts'; import { truncateAddress } from '../utils/nft'; +// ============================================================================== +// BEGINNER REACT LESSON: Page Layout & Wrappers +// In React, a "Layout" component wraps around every page ({children}). +// It provides a consistent navigation Header at the top and Footer at the bottom. +// ============================================================================== + type LayoutProps = { children: ReactNode; }; @@ -11,12 +16,15 @@ type LayoutProps = { export default function DashboardLayout({ children }: LayoutProps) { return (
+ {/* Top Header Navigation Bar */}
+ {/* Main Page Content */}
{children}
+ {/* Bottom Page Footer */}