diff --git a/pages/_app.js b/pages/_app.js index ee9e519cb..5e4f5c108 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -101,6 +101,8 @@ const MyApp = ({ Component, pageProps }) => { '/admin/watchlist', '/nft/[[...id]]', '/tokens', + '/nft/[[...id]]', + '/nft-collection/[id]', '/token/[[...id]]' ] const skipOnFirstRender = [ diff --git a/pages/nft-collection/[id].js b/pages/nft-collection/[id].js new file mode 100644 index 000000000..31519ff3d --- /dev/null +++ b/pages/nft-collection/[id].js @@ -0,0 +1,635 @@ +import { useTranslation } from 'next-i18next' +import { useState, useEffect } from 'react' +import axios from 'axios' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import Link from 'next/link' + +import { usernameOrAddress, AddressWithIconFilled, shortHash, addressUsernameOrServiceLink, amountFormat, convertedAmount, nativeCurrencyToFiat } from '../../utils/format' +import { getIsSsrMobile } from '../../utils/mobile' +import { nftUrl, nftName, ipfsUrl } from '../../utils/nft' +import { axiosServer, passHeaders } from '../../utils/axios' + +import SEO from '../../components/SEO' +import { nftClass } from '../../styles/pages/nft.module.scss' + +export async function getServerSideProps(context) { + const { locale, query, req } = context + let pageMeta = null + const { id } = query + const collectionId = id ? (Array.isArray(id) ? id[0] : id) : '' + + if (collectionId) { + try { + // Try collection-specific endpoint first, fallback to NFT endpoint + let res = await axiosServer({ + method: 'get', + url: 'v2/nft-collection/' + collectionId, + headers: passHeaders(req) + }) + pageMeta = res?.data?.collection || res?.data + } catch (error) { + console.error(error) + } + } + + return { + props: { + id: collectionId, + pageMeta: pageMeta || {}, + isSsrMobile: getIsSsrMobile(context), + ...(await serverSideTranslations(locale, ['common', 'nft'])) + } + } +} + +export default function NftCollection({ pageMeta, id, selectedCurrency, isSsrMobile, fiatRate }) { + const { t } = useTranslation() + const [data, setData] = useState(pageMeta) + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [mounted, setMounted] = useState(false) + const [nftList, setNftList] = useState([]) + const [nftListLoading, setNftListLoading] = useState(false) + const statistics = data?.collection?.statistics + const issuerDetails = data?.collection?.issuerDetails + const collection = data?.collection + const [activityData, setActivityData] = useState({ + sales: [], + listings: [], + mints: [] + }) + const [activityLoading, setActivityLoading] = useState(false) + const [isMobile, setIsMobile] = useState(isSsrMobile) + + useEffect(() => { + setMounted(true) + // Client-side viewport fallback for mobile detection + const updateIsMobile = () => { + try { + const width = window.innerWidth || document.documentElement.clientWidth + setIsMobile(isSsrMobile || width <= 768) + } catch (_) {} + } + updateIsMobile() + window.addEventListener('resize', updateIsMobile) + return () => window.removeEventListener('resize', updateIsMobile) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (!selectedCurrency || !id) return + checkApi() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, selectedCurrency]) + + useEffect(() => { + if (!mounted) return + if (!id) return + fetchCollectionNfts() + fetchActivityData() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mounted, id]) + + const checkApi = async () => { + if (!id) return + + setLoading(true) + // Try collection-specific endpoint first, fallback to NFT endpoint + let response = await axiosServer({ + method: 'get', + url: 'v2/nft-collection/' + id + '?floorPrice=true&statistics=true&assets=true' + }).catch((error) => { + setErrorMessage(t('error.' + error.message)) + return null + }) + + setLoading(false) + const newdata = response?.data + + if (newdata) { + setData(newdata) + } else { + setErrorMessage('No data found') + } + } + + const fetchCollectionNfts = async () => { + setNftListLoading(true) + const url = `/v2/nfts?collection=${encodeURIComponent(id)}&limit=16&order=mintedNew&hasMedia=true` + const res = await axios(url).catch(() => null) + setNftListLoading(false) + const list = res?.data?.nfts || [] + setNftList(list) + } + + const fetchActivityData = async () => { + setActivityLoading(true) + + try { + // Fetch recent sales + const salesRes = await axios(`/v2/nft-sales?collection=${encodeURIComponent(id)}&list=lastSold&limit=3&convertCurrencies=${selectedCurrency}`).catch( + () => null + ) + + // Fetch recent listings (NFTs on sale) + const listingsRes = await axios( + `/v2/nfts?collection=${encodeURIComponent(id)}&list=onSale&order=offerCreatedNew&limit=3¤cy=${selectedCurrency}` + ).catch(() => null) + + // Fetch recent mints + const mintsRes = await axios(`/v2/nfts?collection=${encodeURIComponent(id)}&limit=3&order=mintedNew`).catch( + () => null + ) + setActivityData({ + sales: salesRes?.data?.sales || [], + listings: listingsRes?.data?.nfts || [], + mints: mintsRes?.data?.nfts || [] + }) + } catch (error) { + console.error('Error fetching activity data:', error) + setActivityData({ sales: [], listings: [], mints: [] }) + } finally { + setActivityLoading(false) + } + } + + const collectionName = (data) => { + return data?.collection?.name || + <> + {addressUsernameOrServiceLink(data?.collection, 'issuer', { short: isMobile })} ({data?.collection?.taxon}) + + } + + const collectionDescription = (data) => { + return data?.collection?.description || '' + } + + const imageUrl = ipfsUrl(data?.collection?.image) + + const renderActivityTable = (kind) => { + let title = '' + let headers = [] + let items = [] + if (kind === 'sales') { + title = 'Recent Sales' + headers = ['NFT', 'Seller / Buyer', 'Price', 'Date'] + items = activityData.sales || [] + } else if (kind === 'listings') { + title = 'Recent Listings' + headers = ['NFT', 'Owner', 'Price', 'Date'] + items = activityData.listings || [] + } else if (kind === 'mints') { + title = 'Recent Mints' + headers = ['NFT', 'Owner', 'Date'] + items = activityData.mints || [] + } + + // Mobile: render blocks instead of table + if (isMobile) { + return ( +
+ + + + + + + + { + items.length > 0 ? items.map((item, i) => { + return ( + + + + ) + }) : ( + + + + ) + } + +
{title}
+
+ NFT: + {nftUrl(item?.nftoken || item, 'thumbnail') ? ( + {nftName(item?.nftoken + ) : ( + '' + )} +
+ {nftName(item?.nftoken || item)} + {shortHash(item.nftokenID, 6)} +
+
+
+ {kind === 'sales' && ( + <> +
+ Seller: + {item.seller ? ( + + ) : ( + '' + )} +
+
+ Buyer: + {item.buyer ? ( + + ) : ( + '' + )} +
+
+ Price: + {amountFormat(item.amount)} + ≈ {convertedAmount(item, selectedCurrency, { short: true })} +
+
+ Date: + {item.acceptedAt ? new Date(item.acceptedAt * 1000).toLocaleDateString() : ''} +
+ + )} + {kind === 'listings' && ( + <> +
+ Owner: + {item.owner && addressUsernameOrServiceLink(item, 'owner', { short: 12 })} +
+
+ Price: + {amountFormat(item?.sellOffers?.[0]?.amount)} + {nativeCurrencyToFiat({amount: item?.sellOffers?.[0]?.amount, selectedCurrency, fiatRate})} +
+
+ Date: + {new Date(item.ownerChangedAt * 1000).toLocaleDateString()} +
+ + )} + {kind === 'mints' && ( + <> +
+ Owner: + {item.owner && addressUsernameOrServiceLink(item, 'owner', { short: 12 })} +
+
+ Date: + {new Date(item.issuedAt * 1000).toLocaleDateString()} +
+ + )} +
+
+ {'No data'} +
+
+ ) + } + + // Desktop: render table + return ( + + + + + + + + + {items.length > 0 && headers.map((h, i) => ( + + ))} + + {items.length > 0 ? ( + items.map((item, i) => ( + + + {kind === 'sales' && ( + <> + + + + + )} + {kind === 'listings' && ( + <> + + + + + )} + {kind === 'mints' && ( + <> + + + + )} + + )) + ) : ( + + + + )} + +
{title}
+ {h} +
+
+ {nftUrl(item?.nftoken || item, 'thumbnail') ? ( + {nftName(item?.nftoken + ) : ( + '' + )} + + {nftName(item?.nftoken || item)} + {shortHash(item.nftokenID, 6)} + +
+
+ {item.seller ? ( + + ) : ( + '—' + )} + {item.buyer ? ( + + ) : ( + '—' + )} + + {amountFormat(item.amount)} + ≈ {convertedAmount(item, selectedCurrency, { short: true })} + {item.acceptedAt ? new Date(item.acceptedAt * 1000).toLocaleDateString() : 'N/A'} + {item.owner && } + + {amountFormat(item?.sellOffers?.[0]?.amount)} + {nativeCurrencyToFiat({amount: item?.sellOffers?.[0]?.amount, selectedCurrency, fiatRate})} + {new Date(item.ownerChangedAt * 1000).toLocaleDateString()} + {item.owner && } + {new Date(item.issuedAt * 1000).toLocaleDateString()}
+ {'No data'} +
+ ) + } + + return ( +
+ +
+ {id && !data?.error ? ( + <> + {loading ? ( +
+ +
+ {t('general.loading')} +
+ ) : ( + <> + {errorMessage ? ( +
{errorMessage}
+ ) : ( + <> + {data && ( + <> +
+
+ {imageUrl ? ( + {collectionName(data)} + ) : ( +
No Image Available
+ )} +
+
+ +
+
+

{collectionName(data)}

+ {collectionDescription(data) && ( +

{collectionDescription(data)}

+ )} +
+ + {mounted && data?.collection?.issuerDetails && ( + + + + + + + + + + + + {issuerDetails?.username && ( + + + + + )} + {data?.collection?.issuerDetails?.service && ( + + + + + )} + {collection?.taxon ? ( + + + + + ) : null} + {collection?.createdAt && ( + + + + + )} + {collection?.updatedAt && ( + + + + + )} + +
Issuer Information
Address + +
Username{issuerDetails.username}
Service{issuerDetails.service}
Taxon{collection?.taxon}
Created At{new Date(collection?.createdAt * 1000).toLocaleString()}
Updated At{new Date(collection?.updatedAt * 1000).toLocaleString()}
+ )} + + {mounted && statistics && ( + + + + + + + + {statistics?.nfts && ( + + + + + )} + {statistics?.owners && ( + + + + + )} + {statistics?.all?.tradedNfts && ( + + + + + )} + {statistics?.all?.buyers && ( + + + + + )} + {statistics?.month?.tradedNfts && ( + + + + + )} + {statistics?.week?.tradedNfts ? ( + + + + + ) : null} + +
Collection Statistics
Total Supply{statistics.nfts}
Unique Owners{statistics.owners}
Total Traded NFTs{statistics.all.tradedNfts}
Total Buyers{statistics.all.buyers}
Monthly Traded NFTs{statistics.month.tradedNfts}
Weekly Traded NFTs{statistics.week.tradedNfts}
+ )} + + {mounted && ( + + + + + + + + + + + +
+ NFTs in this Collection + {collection?.issuer && + (collection?.taxon || collection?.taxon === 0) ? ( + <> + {' '} + [ + View all + ] + + ) : ( + collection?.collection && ( + <> + {' '} + [ + View all + ] + + ) + )} +
+ {nftListLoading && ( +
+ +
+ )} + + {!nftListLoading && nftList.length === 0 && ( +
No NFTs found
+ )} + + {!nftListLoading && nftList.length > 0 && ( +
+ { + nftList.map((nft, i) => ( + + {nftName(nft)} + + )) + } +
+ )} +
+ )} + + {mounted && ( +
+ {activityLoading && ( +
+ +
+ )} + + {!activityLoading && ( +
+ {renderActivityTable('sales')} + {renderActivityTable('listings')} + {renderActivityTable('mints')} +
+ )} +
+ )} +
+ + )} + + )} + + )} + + ) : ( + <> +

NFT Collection

+

{data?.error || t('desc', { ns: 'nft' })}

+ + )} +
+
+ ) +} diff --git a/pages/nft-volumes/index.js b/pages/nft-volumes/index.js index 7b381f109..312a82ee4 100644 --- a/pages/nft-volumes/index.js +++ b/pages/nft-volumes/index.js @@ -648,6 +648,23 @@ export default function NftVolumes({ return {text || } } + const nftCollectionLink = (data, options = {}) => { + if (!data) return '' + const { text } = options + + if (data.collectionDetails?.issuer || data.collectionDetails?.name) { + let collectionId + if(data.collectionDetails.issuer) { + collectionId = data.collectionDetails.issuer + ':' + data.collectionDetails.taxon + } else { + collectionId = data.collection + } + return {text || } + } + + return '' + } + const nftDistributionLink = (data) => { if (!data) return '' let params = '?issuer=' @@ -917,7 +934,7 @@ export default function NftVolumes({ ) } - let nameLink = nftExplorerLink(data, { + let nameLink = nftCollectionLink(data, { text: name || collectionNameText(data) })