diff --git a/src/pages/communities/nft/[address].tsx b/src/pages/communities/nft/[address].tsx index 8229191d..b485a55d 100644 --- a/src/pages/communities/nft/[address].tsx +++ b/src/pages/communities/nft/[address].tsx @@ -3,34 +3,83 @@ import { Button } from '@/components/Button' import { Chip } from '@/components/Chip/Chip' import { MainContainer } from '@/components/MainContainer/MainContainer' import { Paragraph, Span, Typography } from '@/components/Typography' -import { cn, truncateMiddle, shortAddress } from '@/lib/utils' +import { cn, truncateMiddle } from '@/lib/utils' import Image from 'next/image' import { useRouter } from 'next/router' -import { ReactNode, useState } from 'react' +import { ReactNode, useState, useEffect } from 'react' import { BsTwitterX } from 'react-icons/bs' import { FaDiscord, FaLink } from 'react-icons/fa' import { Address } from 'viem' import { useAccount } from 'wagmi' import { useCommunity } from '@/shared/hooks/useCommunity' -import { useMintNFT } from '@/shared/hooks/useMintNFT' -import { useNftMeta } from '@/shared/hooks/useNFTMeta' import { CopyButton } from '@/components/CopyButton' +/** + * Name of the local storage variable with information about whether the token was added to the wallet + */ +const IS_IN_WALLET = 'isInWallet' +/** + * Type representing localStorage and state variable for tracking if an NFT token is added to the wallet + * The structure of the variable is the following: + * ```json + * { + * "0x123456...123456": { + * 1: true, + * 2: true + * } + * } + * ``` + */ +type IsInWallet = Record> + +/** + * Early Adopter community page + */ export default function Page() { const router = useRouter() const nftAddress = router.query.address as Address | undefined const { address } = useAccount() - const { tokensAvailable, isMember, tokenId, membersCount, nftName } = useCommunity(nftAddress) - const { onMintNFT, isPending: isClaiming } = useMintNFT(nftAddress) + const { + tokensAvailable, + isMember, + tokenId, + membersCount, + nftName, + nftSymbol, + mint: { onMintNFT, isPending: isClaiming }, + nftMeta, + } = useCommunity(nftAddress) + const [message, setMessage] = useState('') + // reset message after few seconds + useEffect(() => { + if (!message) return + const timeout = setTimeout(() => setMessage(''), 5000) + return () => clearTimeout(timeout) + }, [message]) + // read from local storage if the NFT was added to the wallet + const [isNftInWallet, setIsNftInWallet] = useState(() => { + // prevent from server execution + if (typeof window !== 'undefined') { + const storedValue = window.localStorage.getItem(IS_IN_WALLET) + return storedValue !== null ? JSON.parse(storedValue) : {} + } else { + return {} + } + }) - const { meta } = useNftMeta(nftAddress) + // synchronize local storage variable + useEffect(() => { + // prevent from server execution + if (typeof window !== 'undefined') { + window.localStorage.setItem(IS_IN_WALLET, JSON.stringify(isNftInWallet)) + } + }, [isNftInWallet]) const handleMinting = () => { if (!address) return onMintNFT() .then(txHash => { - console.log('SUCCESS', txHash) setMessage( 'Request transaction sent. Your claim is in process. It will be visible when the transaction is confirmed.', ) @@ -45,6 +94,40 @@ export default function Page() { }) } + /** + * Adds NFT to wallet collection + */ + const addToWallet = async () => { + try { + if (typeof window === 'undefined' || !window.ethereum) throw new Error('Wallet is not installed') + if (!nftAddress || !tokenId) throw new Error('Unknown NFT') + // connect wallet in case it was disconnected + await window.ethereum.request({ + method: 'eth_requestAccounts', + }) + const wasAdded = await window.ethereum.request({ + method: 'wallet_watchAsset', + params: { + type: 'ERC721', + options: { + address: nftAddress, + symbol: nftSymbol, + image: nftMeta?.image, + tokenId: String(tokenId), + }, + }, + }) + if (!wasAdded) throw new Error('Unable to add NFT to wallet') + setIsNftInWallet(old => ({ ...old, [nftAddress]: { ...old[nftAddress], [tokenId]: true } })) + setMessage(`NFT#${tokenId} was added to wallet`) + } catch (error) { + // don't show error message if user has closed the wallet prompt + if ((error as { message?: string }).message?.includes('User rejected the request')) return + console.error('ERROR', error) + setMessage(`Error adding NFT#${tokenId} to wallet`) + } + } + if (!nftAddress) return null return ( @@ -95,9 +178,11 @@ export default function Page() { {/* pioneer, holders, followers */}
{truncateMiddle(nftAddress as string)} + + {truncateMiddle(nftAddress as string, 4, 4)} + } /> @@ -109,13 +194,13 @@ export default function Page() { Membership NFT
{meta?.name - {isMember ? ( + {isMember && tokenId ? (
Early Adopter #{tokenId} @@ -126,13 +211,20 @@ export default function Page() { Owned{address && ' by '} {address && ( - {shortAddress(address, 3)} + {truncateMiddle(address, 4, 3)} )}
+ {/* `Add to wallet button` */} + {!isNftInWallet?.[nftAddress]?.[tokenId] && ( + + )} + - {meta?.description} + {nftMeta?.description}
) : ( diff --git a/src/shared/hooks/useCommunity.ts b/src/shared/hooks/useCommunity.ts index eea10bb0..ab572575 100644 --- a/src/shared/hooks/useCommunity.ts +++ b/src/shared/hooks/useCommunity.ts @@ -1,85 +1,116 @@ -import { useMemo } from 'react' +import { useMemo, useCallback, useEffect, useState } from 'react' import { abiContractsMap } from '@/lib/contracts' import { Address } from 'viem' -import { useReadContracts, useAccount } from 'wagmi' +import { useReadContracts, useAccount, useWaitForTransactionReceipt, useWriteContract } from 'wagmi' +import { fetchIpfsUri } from '@/app/user/Balances/actions' +import { NftMeta, CommunityData } from '../types' /** - * useCommunity hook return properties + * Hook for loading NFT metadata from IPFS */ -interface CommunityData { - /** - * The remaining number of tokens that can be minted - */ - tokensAvailable: number - /** - * Number of community members who received tokens - */ - membersCount: number - /** - * Tells whether the user is a member of the community - */ - isMember: boolean - /** - * Serial number of the token minted for the user - */ - tokenId: number | undefined - /** - * NFT smart contract name - */ - nftName: string | undefined +const useNftMeta = (nftUri?: string) => { + const [nftMeta, setNftMeta] = useState() + + useEffect(() => { + if (!nftUri) return setNftMeta(undefined) + fetchIpfsUri(nftUri).then(async nftMeta => { + const response = await fetchIpfsUri(nftMeta.image, 'blob') + const url = URL.createObjectURL(response) + setNftMeta({ ...nftMeta, image: url }) + }) + }, [nftUri]) + + return nftMeta } /** - * Reads different Early Adopters NFT contract functions - * @param nftAddress deployed contract address - * @returns community NFT view functions call results + * Hook for reading NFT contract view functions */ -export const useCommunity = (nftAddress?: Address): CommunityData => { +export const useContractData = (nftAddress?: Address) => { const { address } = useAccount() const contract = { // verifying `nftAddress` later in `useReadContracts` params abi: abiContractsMap[nftAddress!], address: nftAddress, } - const { data } = useReadContracts( + + // load data from the contract view functions + const { data, refetch } = useReadContracts( address && nftAddress && { contracts: [ - { - ...contract, - functionName: 'totalSupply', - }, - { - ...contract, - functionName: 'tokensAvailable', - }, - { - ...contract, - functionName: 'balanceOf', - args: [address], - }, - { - ...contract, - functionName: 'tokenIdByOwner', - args: [address], - }, - { - ...contract, - functionName: 'name', - }, + { ...contract, functionName: 'totalSupply' }, + { ...contract, functionName: 'tokensAvailable' }, + { ...contract, functionName: 'balanceOf', args: [address] }, + { ...contract, functionName: 'tokenIdByOwner', args: [address] }, + { ...contract, functionName: 'name' }, + { ...contract, functionName: 'symbol' }, + { ...contract, functionName: 'tokenUriByOwner', args: [address] }, ], }, ) return useMemo(() => { - const [membersCount, tokensAvailable, balanceOf, tokenIdByOwner, nftName] = data ?? [] - const communityData: CommunityData = { - tokensAvailable: Number(tokensAvailable?.result ?? 0n), + const [membersCount, tokensAvailable, balanceOf, tokenIdByOwner, nftName, symbol, nftUri] = data ?? [] + return { + refetch, membersCount: Number(membersCount?.result ?? 0n), + tokensAvailable: Number(tokensAvailable?.result ?? 0n), isMember: (balanceOf?.result ?? 0n) > 0n, tokenId: typeof tokenIdByOwner?.result === 'bigint' ? Number(tokenIdByOwner.result) : undefined, nftName: nftName?.result, + nftSymbol: symbol?.result, + nftUri: nftUri?.result, } - return communityData - }, [data]) + }, [data, refetch]) +} + +/** + * Hook for executing and watching NFT mint transaction + */ +const useMintNFT = (nftAddress?: Address, tokensAvailable?: number) => { + const { writeContractAsync: mint, isPending, data: hash } = useWriteContract() + const { isLoading, isSuccess } = useWaitForTransactionReceipt({ hash }) + + const onMintNFT = useCallback(async () => { + if (!nftAddress) throw new Error('Unknown NFT address') + if (!tokensAvailable) throw new Error('No NFTs available to mint') + return await mint({ + abi: abiContractsMap[nftAddress], + address: nftAddress || '0x0', + functionName: 'mint', + args: [], + }) + }, [mint, nftAddress, tokensAvailable]) + + return useMemo( + () => ({ onMintNFT, isPending: isLoading || isPending, isSuccess }), + [isLoading, isPending, isSuccess, onMintNFT], + ) +} + +/** + * Hook returning all information about Early Adopters community + */ +export const useCommunity = (nftAddress?: Address): CommunityData => { + const { refetch, ...data } = useContractData(nftAddress) + const { onMintNFT, isPending, isSuccess } = useMintNFT(nftAddress, data.tokensAvailable) + const nftMeta = useNftMeta(data.nftUri) + + useEffect(() => { + if (isSuccess) refetch() + }, [isSuccess, refetch]) + + return useMemo( + () => + ({ + ...data, + mint: { + onMintNFT, + isPending, + }, + nftMeta, + }) satisfies CommunityData, + [data, isPending, nftMeta, onMintNFT], + ) } diff --git a/src/shared/hooks/useMintNFT.ts b/src/shared/hooks/useMintNFT.ts deleted file mode 100644 index 7eda71a0..00000000 --- a/src/shared/hooks/useMintNFT.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { abiContractsMap } from '@/lib/contracts' -import { Address } from 'viem' -import { useWriteContract } from 'wagmi' -import { useCommunity } from './useCommunity' - -export const useMintNFT = (nftAddress?: Address) => { - const { tokensAvailable } = useCommunity(nftAddress) - const { writeContractAsync: mint, isPending } = useWriteContract() - - const onMintNFT = async () => { - if (!nftAddress) throw new Error('Unknown NFT address') - if (!tokensAvailable) throw new Error('No NFTs available to mint') - return await mint({ - abi: abiContractsMap[nftAddress], - address: nftAddress || '0x0', - functionName: 'mint', - args: [], - }) - } - - return { onMintNFT, isPending } -} diff --git a/src/shared/hooks/useNFTMeta.ts b/src/shared/hooks/useNFTMeta.ts deleted file mode 100644 index a6e92922..00000000 --- a/src/shared/hooks/useNFTMeta.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { fetchIpfsUri } from '@/app/user/Balances/actions' -import { abiContractsMap } from '@/lib/contracts' -import { useReadContract } from 'wagmi' -import { useEffect, useState, useMemo } from 'react' -import { Address } from 'viem' -import { useAccount } from 'wagmi' -import { NftMeta } from '../types' - -interface MetaReturnType { - /** - * Early Adopters NFT metadata - */ - meta?: NftMeta - /** - * NFT image loading status - */ - isLoadingNftImage: boolean -} - -/** - * Reads metadata of the Early Adopters community NFT owned by the address - * and substitutes image URI with a blob - * @param nftAddress NFT smart contract address - * @returns NFT metadata - */ -export const useNftMeta = (nftAddress?: Address): MetaReturnType => { - const { address } = useAccount() - const [isLoadingNftImage, setIsLoadingNftImage] = useState(!!address) - const [meta, setMeta] = useState() - - // load contract data and get NFT URI and tokenId by owner - const { data: nftUri } = useReadContract( - address && - nftAddress && { - abi: abiContractsMap[nftAddress], - address: nftAddress, - functionName: 'tokenUriByOwner', - args: [address], - }, - ) - - // load NFT image and metadata - useEffect(() => { - if (!nftUri) return - setIsLoadingNftImage(true) - fetchIpfsUri(nftUri) - .then(async nftMeta => { - const response = await fetchIpfsUri(nftMeta.image, 'blob') - const url = URL.createObjectURL(response) - setMeta({ ...nftMeta, image: url }) - }) - .finally(() => setIsLoadingNftImage(false)) - }, [nftUri]) - - return useMemo(() => ({ meta, isLoadingNftImage }), [meta, isLoadingNftImage]) -} diff --git a/src/shared/types.ts b/src/shared/types.ts index 3adf0e35..008d06b7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,3 +1,5 @@ +import { Address } from 'viem' + /** * NFT metadata properties from JSON metadata files */ @@ -23,3 +25,46 @@ export interface NftMeta { */ creator: string } +/** + * useCommunity hook return properties + */ +export interface CommunityData { + /** + * The remaining number of tokens that can be minted + */ + tokensAvailable: number + /** + * Number of community members who received tokens + */ + membersCount: number + /** + * Tells whether the user is a member of the community + */ + isMember: boolean + /** + * Serial number of the token minted for the user + */ + tokenId: number | undefined + /** + * NFT smart contract name + */ + nftName: string | undefined + /** + * Symbol of the Early Adopters NFT + */ + nftSymbol: string | undefined + mint: { + /** + * Function to mint new token + */ + onMintNFT: () => Promise
+ /** + * Flag indicating that NFT is being minted + */ + isPending: boolean + } + /** + * NFT Metadata + */ + nftMeta: NftMeta | undefined +}