diff --git a/frontend-lectures/lesson3/openriver/src/components/Card.tsx b/frontend-lectures/lesson3/openriver/src/components/Card.tsx index 0c24e68..eb3bf8e 100644 --- a/frontend-lectures/lesson3/openriver/src/components/Card.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/Card.tsx @@ -1,56 +1,160 @@ -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 - } +interface CardProps { + nft: NFTItemData; + onRefresh: () => void; +} - if (onchainNFT.startsWith('ipfs://')) { - return `https://ipfs.io/ipfs/${onchainNFT.replace('ipfs://', '')}` - } +export const Card: React.FC = ({ nft, onRefresh }) => { + const { address } = useAccount(); + + const [showList, setShowList] = useState(false); + const [showTransfer, setShowTransfer] = useState(false); + const [showApprove, setShowApprove] = useState(false); - return onchainNFT - }, [onchainNFT]) + const { writeContract: buyContract, data: buyHash, isPending: isBuyPending } = useWriteContract(); + const { isLoading: isBuyConfirming, isSuccess: isBuySuccess } = useWaitForTransactionReceipt({ hash: buyHash }); + + const { writeContract: delistContract, data: delistHash, isPending: isDelistPending } = useWriteContract(); + const { isLoading: isDelistConfirming, isSuccess: isDelistSuccess } = useWaitForTransactionReceipt({ hash: delistHash }); + + React.useEffect(() => { + if (isBuySuccess || isDelistSuccess) { + onRefresh(); + } + }, [isBuySuccess, isDelistSuccess, onRefresh]); + const isOwner = Boolean(address && nft.owner.toLowerCase() === address.toLowerCase()); - const {data: marketData} = useReadContract({ - abi: openriverAbi, - address: openriverAddress, - functionName: "marketplace", - args: [BigInt(tokenId)] - }) + const handleQuickBuy = () => { + buyContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'purchase', + args: [nft.tokenId], + value: nft.price, + }); + }; - console.log(marketData?.[0]) + const handleQuickDelist = () => { + delistContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'removeFromMarketplace', + args: [nft.tokenId], + }); + }; return ( -
-
- {normalizedImageSrc ? ( - This one na NFT - ) : ( -

NFT image unavailable

- )} -
-
-

#29

-

200 ETH

+ <> +
+
+
+ {nft.name} +
+ {nft.isListed ? ( + + For Sale + + ) : ( + + Not Listed + + )} +
+
+ + #{nft.tokenId.toString()} + +
+
+ +
+

+ {nft.name} +

+
+ Owner: + + {isOwner ? 'You' : truncateAddress(nft.owner, 4)} + +
+
+
+ +
+
+ + {nft.isListed ? 'Price' : 'Status'} + + + {nft.isListed ? `${nft.priceFormatted} ETH` : 'Not Listed'} + +
+ +
+ {isOwner ? ( + nft.isListed ? ( + + ) : ( + + ) + ) : nft.isListed ? ( + + ) : ( +
+ Not Available +
+ )} +
+
-
- ) -} -export default Card \ No newline at end of file + 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..ad5ad65 100644 --- a/frontend-lectures/lesson3/openriver/src/components/Cards.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/Cards.tsx @@ -1,25 +1,117 @@ -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", - }) +export const Cards = ({ initialFilter }: { initialFilter?: 'all' | 'listed' | 'my' }) => { + const { nfts, loading, refresh } = useNFTs(); + const { address } = useAccount(); + const [activeTab, setActiveTab] = useState<'all' | 'listed' | 'my'>(initialFilter || 'all'); + const [searchTerm, setSearchTerm] = useState(''); + const filteredNFTs = useMemo(() => { + return nfts.filter((nft) => { + if (activeTab === 'listed' && !nft.isListed) return false; + if (activeTab === 'my') { + if (!address) return false; + if (nft.owner.toLowerCase() !== address.toLowerCase()) return false; + } + 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) => ( - - ))} +
+
+
+ + + +
+ +
+ setSearchTerm(e.target.value)} + className="simple-input px-3 py-2 rounded-lg text-xs w-full sm:w-64" + /> + +
+
+ + {loading && ( +
+ Loading NFTs... Please wait. +
+ )} + + {!loading && filteredNFTs.length > 0 && ( +
+ {filteredNFTs.map((nft) => ( + + ))} +
+ )} + + {!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..efaebab 100644 --- a/frontend-lectures/lesson3/openriver/src/components/Header.tsx +++ b/frontend-lectures/lesson3/openriver/src/components/Header.tsx @@ -1,21 +1,52 @@ -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'; const Header = () => { + const router = useRouter(); + + 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 +
+
+ + + 🌊 + OpenRiver NFT Store + + + +
+ +
-
-
- ) -} + + ); +}; -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..85d898f --- /dev/null +++ b/frontend-lectures/lesson3/openriver/src/components/NFTActionModals.tsx @@ -0,0 +1,384 @@ +import React, { useState, useEffect } from 'react'; +import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi'; +import { parseEther } from 'viem'; +import { openriverAbi, openriverAddress } from '../contracts'; +import { NFTItemData, truncateAddress } from '../utils/nft'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + nft: NFTItemData | null; + onSuccess?: () => void; +} + +export const ListModal: React.FC = ({ isOpen, onClose, nft, onSuccess }) => { + const [priceEth, setPriceEth] = useState(''); + + const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + useEffect(() => { + if (isSuccess) { + onSuccess?.(); + onClose(); + setPriceEth(''); + } + }, [isSuccess, onSuccess, onClose]); + + if (!isOpen || !nft) return null; + + const handleList = () => { + if (!priceEth || isNaN(Number(priceEth)) || Number(priceEth) <= 0) return; + try { + const priceWei = parseEther(priceEth); + 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)}... +
+ )} + +
+ + +
+
+
+ ); +}; + +export const TransferModal: React.FC = ({ isOpen, onClose, nft, onSuccess }) => { + const { address } = useAccount(); + const [recipient, setRecipient] = useState(''); + + 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; + + 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)}... +
+ )} + +
+ + +
+
+
+ ); +}; + +export const ApproveModal: React.FC = ({ isOpen, onClose, nft, onSuccess }) => { + const [operator, setOperator] = useState(openriverAddress); + const [isForAll, setIsForAll] = useState(true); + + 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; + + 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)}... +
+ )} + +
+ + +
+
+
+ ); +}; + +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(); + + const { writeContract: buyContract, data: buyHash, isPending: isBuyPending } = useWriteContract(); + const { isLoading: isBuyConfirming, isSuccess: isBuySuccess } = useWaitForTransactionReceipt({ hash: buyHash }); + + 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()); + + const handleBuy = () => { + buyContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'purchase', + args: [nft.tokenId], + value: nft.price, + }); + }; + + const handleDelist = () => { + delistContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'removeFromMarketplace', + args: [nft.tokenId], + }); + }; + + return ( +
+
+ + +
+
+
+ {nft.name} +
+
+ Price +

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

+
+
+ +
+
+

{nft.name}

+

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

+ +
+
+ Token ID: + #{nft.tokenId.toString()} +
+
+ Owner: + {isOwner ? 'You' : truncateAddress(nft.owner, 4)} +
+
+ Creator Royalty: + {nft.royalty.toString()}% +
+
+
+ +
+ {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..4f003d5 --- /dev/null +++ b/frontend-lectures/lesson3/openriver/src/hooks/useNFTs.ts @@ -0,0 +1,134 @@ +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'; + +export function useNFTs() { + const { address } = useAccount(); + const [nfts, setNfts] = useState([]); + const [loading, setLoading] = useState(true); + + const { data: totalTokens, refetch: refetchTotal } = useReadContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'tokenIds', + }); + + const count = totalTokens ? Number(totalTokens) : 0; + + const calls = []; + for (let i = 1; i <= count; i++) { + const tid = BigInt(i); + calls.push({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'tokenURI', + args: [tid], + }); + calls.push({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'ownerOf', + args: [tid], + }); + calls.push({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'marketplace', + args: [tid], + }); + } + + const { data: rawData, refetch: refetchDetails, isLoading: detailsLoading } = useReadContracts({ + contracts: calls as any, + query: { + enabled: count > 0, + } + }); + + const loadAllNFTs = useCallback(async () => { + if (!count || !rawData || rawData.length === 0) { + setNfts([]); + setLoading(false); + return; + } + + setLoading(true); + const items: NFTItemData[] = []; + + 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); + + 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; + } + } + + items.push({ + tokenId, + tokenURI, + owner, + isListed, + price, + priceFormatted: formatPrice(price), + publisher, + royalty, + metadata, + imageSrc, + name, + description, + }); + } + + setNfts(items.reverse()); + setLoading(false); + }, [count, rawData]); + + useEffect(() => { + loadAllNFTs(); + }, [loadAllNFTs]); + + const refresh = useCallback(() => { + refetchTotal(); + refetchDetails(); + }, [refetchTotal, refetchDetails]); + + 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..2fcec7a 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/dashboard/index.tsx @@ -1,35 +1,59 @@ -import { useWriteContract } from "wagmi" +import { useWriteContract } from "wagmi"; import { openriverAbi, openriverAddress } from "../../contracts"; import { useState } from "react"; - const Dashboard = () => { 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 - ] - } - ) - } + mintNFT({ + abi: openriverAbi, + address: openriverAddress, + functionName: "newItem", + args: [ + tokenUrI, + royalty + ] + }); + }; + return ( -
-
- setTokenUrI(e.target.value)} /> - setRoyalty(Number(e.target.value))} /> - +
+

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" + /> +
+ +
- ) -} + ); +}; -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..c59a9b7 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/index.tsx @@ -1,19 +1,62 @@ -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'; const Home: NextPage = () => { + const { totalTokens, listedNFTs, loading } = useNFTs(); + return ( -
+ <> + + OpenRiver | Simple NFT Store + + + +
+
+
+

+ 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. +

+ +
+ + Mint New NFT + + + Sell Your NFT + +
+ +
+
+ Total Minted +

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

+
+
+ Items For Sale +

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

+
+
+
+
-
- -
- -
+
+

Explore NFTs

+ +
+
+ ); }; diff --git a/frontend-lectures/lesson3/openriver/src/pages/layout.tsx b/frontend-lectures/lesson3/openriver/src/pages/layout.tsx index 96b65cd..bc42bda 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/layout.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/layout.tsx @@ -1,16 +1,40 @@ -import type { ReactNode } from 'react' -import Header from '../components/Header' +import type { ReactNode } from 'react'; +import Header from '../components/Header'; +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..399a15b 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/list/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/list/index.tsx @@ -1,35 +1,146 @@ -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 - ] - } - ) +const ListPage: NextPage = () => { + const router = useRouter(); + const { isConnected } = useAccount(); + const { userNFTs } = useNFTs(); + + const [tokenIdInput, setTokenIdInput] = useState(''); + const [priceEth, setPriceEth] = useState(''); + + const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + const selectedNFT = userNFTs.find((item) => item.tokenId.toString() === tokenIdInput); + + 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]); + + const handleList = () => { + if (!tokenIdInput || !priceEth || isNaN(Number(priceEth)) || Number(priceEth) <= 0) return; + try { + const priceWei = parseEther(priceEth); + 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

- ) -} +
+
+ + {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" + /> +
+ + {selectedNFT && ( +
+ {selectedNFT.name} +
+

{selectedNFT.name}

+

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

+
+
+ )} + +
+ + setPriceEth(e.target.value)} + className="simple-input w-full px-3 py-2 rounded text-xs" + /> +
+ + {writeError && ( +
+ Error: {writeError.message.slice(0, 100)}... +
+ )} + + {isSuccess && ( +
+ 🎉 Success! NFT listed for sale. Redirecting to home... +
+ )} + + +
+
+ + ); +}; -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..004ccf8 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/mint/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/mint/index.tsx @@ -1,35 +1,160 @@ -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 - ] - } - ) +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', + }, +]; + +const MintPage: NextPage = () => { + const router = useRouter(); + const { isConnected } = useAccount(); + + const [tokenURI, setTokenURI] = useState(SAMPLE_PRESETS[0].uri); + const [royalty, setRoyalty] = useState(5); + + const { writeContract, data: hash, isPending, error: writeError } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + 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]); + + 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

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

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

+
+ +
+ Preview: +
+ {tokenURI ? ( + Preview + ) : ( +
+ No Image +
+ )} +
+
+ + {writeError && ( +
+ Error: {writeError.message.slice(0, 100)}... +
+ )} + + {isSuccess && ( +
+ 🎉 Success! NFT Minted. Redirecting to My NFTs... +
+ )} + + +
+
+ + ); +}; -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..d8607c8 100644 --- a/frontend-lectures/lesson3/openriver/src/pages/myNFT/index.tsx +++ b/frontend-lectures/lesson3/openriver/src/pages/myNFT/index.tsx @@ -1,37 +1,82 @@ -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() +const MyNFTPage: NextPage = () => { + const { address, isConnected } = useAccount(); + const { refresh } = useNFTs(); - const { data: nftmaxNum } = useReadContract({ - abi: openriverAbi, - address: openriverAddress, - functionName: "tokenIds", - }) as any + 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]) + const { writeContract, data: hash, isPending } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); - useEffect(() => { - setNftsNum(nftmaxNum) - }, [nftmaxNum]) + useEffect(() => { + if (isSuccess) { + refetchApproval(); + refresh(); + } + }, [isSuccess, refetchApproval, refresh]); + const handleToggleApproval = () => { + if (!address) return; + writeContract({ + abi: openriverAbi, + address: openriverAddress, + functionName: 'setApprovalForAll', + args: [openriverAddress, !isApprovedForAll], + }); + }; - return ( -
+ return ( + <> + + My Collection | OpenRiver + -

{nftsNum?.toString()}

-
- -
+
+
+
+

My NFT Collection

+

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

+
+ {isConnected && ( +
+ + Marketplace Approval: {isApprovedForAll ? '✓ Enabled' : 'Disabled'} + + +
+ )}
- ); + + +
+ + ); }; -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..d70ecbf --- /dev/null +++ b/frontend-lectures/lesson3/openriver/src/utils/nft.ts @@ -0,0 +1,119 @@ +import { formatEther } from 'viem'; + +export interface NFTMetadata { + name?: string; + description?: string; + image?: string; + attributes?: Array<{ trait_type: string; value: string | number }>; +} + +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; +} + +export function resolveIPFS(url: string | undefined | null): string { + if (!url) return ''; + const cleanUrl = url.trim(); + + if (cleanUrl.startsWith('ipfs://')) { + const cid = cleanUrl.replace('ipfs://', ''); + return `https://ipfs.io/ipfs/${cid}`; + } + + return cleanUrl; +} + +export async function fetchNFTMetadata(tokenURI: string): Promise { + if (!tokenURI) return null; + + const resolved = resolveIPFS(tokenURI); + + if ( + resolved.match(/\.(jpeg|jpg|gif|png|svg|webp)$/i) || + resolved.startsWith('data:image/') || + resolved.includes('picsum.photos') || + resolved.includes('unsplash.com') + ) { + return { + image: resolved, + name: undefined, + description: undefined, + }; + } + + try { + if (resolved.startsWith('http://') || resolved.startsWith('https://')) { + const res = await fetch(resolved); + + if (res.ok) { + const contentType = res.headers.get('content-type') || ''; + + if (contentType.includes('image/')) { + return { image: resolved }; + } + + try { + 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 { + return { image: resolved }; + } + } + } + } catch (error) { + console.log('Using image fallback for tokenURI:', tokenURI); + } + + return null; +} + +export function truncateAddress(address: string, chars = 4): string { + if (!address) return ''; + return `${address.substring(0, chars + 2)}...${address.substring(address.length - chars)}`; +} + +export function formatPrice(priceWei: bigint | undefined | null): string { + if (priceWei === undefined || priceWei === null) return '0'; + try { + return formatEther(priceWei); + } catch { + return '0'; + } +} + +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)}`; +}