diff --git a/app/NFT/collection/[collectionId]/page.tsx b/app/NFT/collection/[collectionId]/page.tsx index 0c950f7..059d7d0 100644 --- a/app/NFT/collection/[collectionId]/page.tsx +++ b/app/NFT/collection/[collectionId]/page.tsx @@ -173,6 +173,14 @@ export default function CollectionDetailsPage() { setLoading(true); try { + // For BNB Chain, show a loading toast to indicate it might take some time + if (networkId === '0x38' || networkId === '0x61') { + toast({ + title: 'Loading BNB Chain NFTs', + description: 'This may take a moment as we fetch data from BSCScan...', + }); + } + const metadata = await fetchCollectionInfo(collectionId, networkId); setCollection({...metadata, chain: networkId}); @@ -194,7 +202,10 @@ export default function CollectionDetailsPage() { })); setNfts(nftsWithChain); - setTotalPages(Math.ceil(nftData.totalCount / pageSize)); + + // Calculate total pages - may be different for BNB Chain + const totalPagesCount = Math.max(1, Math.ceil(nftData.totalCount / pageSize)); + setTotalPages(totalPagesCount); // Extract attributes for filtering const attributeMap: Record = {}; @@ -213,9 +224,9 @@ export default function CollectionDetailsPage() { // Add network as a filter attribute attributeMap['Network'] = [ - chainId === '0x1' ? 'Ethereum' : - chainId === '0xaa36a7' ? 'Sepolia' : - chainId === '0x38' ? 'BNB Chain' : + networkId === '0x1' ? 'Ethereum' : + networkId === '0xaa36a7' ? 'Sepolia' : + networkId === '0x38' ? 'BNB Chain' : 'BNB Testnet' ]; @@ -303,7 +314,7 @@ export default function CollectionDetailsPage() { return newFilters; }); - // Clear pagination cache + // Apply filter changes handleFilterChange(); }; @@ -633,10 +644,8 @@ export default function CollectionDetailsPage() { const styles = getAttributeStyles(traitType, value); return (
- +
); })} @@ -708,20 +718,27 @@ export default function CollectionDetailsPage() { {getSortedAttributeValues(traitType, values).map((value) => { const styles = getAttributeStyles(traitType, value); return ( - +
+ handleAttributeFilter(traitType, value)} + /> +
+ + ); })} @@ -757,8 +774,15 @@ export default function CollectionDetailsPage() { className="pl-10 bg-gray-800/50 border-gray-700" value={searchQuery} onChange={handleSearchChange} - onKeyDown={handleSearchKeyDown} + onKeyDown={(e) => e.key === 'Enter' && handleFilterChange()} /> +
@@ -874,7 +898,7 @@ export default function CollectionDetailsPage() { attributes={selectedAttributes} viewMode={viewMode} onNFTClick={handleNFTClick} - itemsPerPage={20} // Reduced to exactly 20 items per page + itemsPerPage={20} // Using exactly 20 items per page defaultPage={currentPage} onPageChange={(page) => { setCurrentPage(page); diff --git a/app/NFT/layout.tsx b/app/NFT/layout.tsx index 34f8dbd..9f37a02 100644 --- a/app/NFT/layout.tsx +++ b/app/NFT/layout.tsx @@ -17,7 +17,7 @@ export default function NFTLayout({ children }: { children: React.ReactNode }) { // Determine active section based on URL const isMarketplace = pathname === '/NFT'; - const isCollections = pathname.includes('/NFT/collection'); + const isCollections = pathname ? pathname.includes('/NFT/collection') : false; useEffect(() => { setMounted(true); @@ -114,13 +114,13 @@ export default function NFTLayout({ children }: { children: React.ReactNode }) {
  • - - Collections - + + Collections +
  • )} - {pathname.includes('/NFT/collection/') && pathname !== '/NFT/collection' && ( + {pathname && pathname.includes('/NFT/collection/') && pathname !== '/NFT/collection' && (
  • diff --git a/app/api/coingecko-proxy/route.ts b/app/api/coingecko-proxy/route.ts new file mode 100644 index 0000000..d33141a --- /dev/null +++ b/app/api/coingecko-proxy/route.ts @@ -0,0 +1,48 @@ + +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const endpoint = searchParams.get('endpoint'); + + if (!endpoint) { + return NextResponse.json({ error: 'Endpoint is required' }, { status: 400 }); + } + + try { + const response = await fetch(`https://api.coingecko.com/api/v3/${endpoint}`, { + headers: { + 'Accept': 'application/json', + // Nếu bạn có API key trả phí, thêm vào đây + // 'x-cg-api-key': 'your-api-key-here', + }, + }); + + if (!response.ok) { + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After') || '60'; // Mặc định 60s nếu không có header + return NextResponse.json( + { + error: 'Rate limit exceeded. Please try again later.', + retryAfter: parseInt(retryAfter), + }, + { status: 429 } // Trả về 429 thay vì ném lỗi + ); + } + throw new Error(`CoinGecko API responded with status ${response.status}`); + } + + const data = await response.json(); + return NextResponse.json({ data }); + } catch (error) { + console.error('Proxy error:', error); + return NextResponse.json( + { error: 'Failed to fetch from CoinGecko' }, + { status: 500 } + ); + } +} + +export const config = { + runtime: 'nodejs', +}; \ No newline at end of file diff --git a/app/api/nfts/collection/route.ts b/app/api/nfts/collection/route.ts new file mode 100644 index 0000000..e5794a8 --- /dev/null +++ b/app/api/nfts/collection/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server'; +import axios from 'axios'; + +// Rate limiting configuration +const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute window +const MAX_REQUESTS_PER_WINDOW = 50; // 50 requests per minute + +// Basic in-memory rate limiter +const rateLimiter = new Map(); + +export async function GET(req: NextRequest) { + // Get URL from searchParams + const url = req.nextUrl.searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: 'URL parameter is required' }, { status: 400 }); + } + + // Apply rate limiting based on IP + const ip = req.headers.get('x-forwarded-for') || 'unknown'; + const clientId = String(ip); + + // Check rate limit + if (!checkRateLimit(clientId)) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ); + } + + try { + // Validate that the URL is for the expected API + if ( + !url.includes('alchemy.com/nft/') && + !url.includes('moralis.io/api/') && + !url.includes('etherscan.io/api') && + !url.includes('bscscan.com/api') + ) { + return NextResponse.json({ error: 'Invalid API URL' }, { status: 403 }); + } + + // Make the request to the NFT API service + const response = await axios.get(url, { + headers: { + 'Accept': 'application/json', + }, + // Handle timeouts and large responses + timeout: 10000, + maxContentLength: 10 * 1024 * 1024, // 10MB max + validateStatus: (status) => status < 500, + }); + + // Return the data from the API + return NextResponse.json(response.data, { status: response.status }); + } catch (error: any) { + console.error('API proxy error:', error); + + // Handle different error types + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + return NextResponse.json({ + error: 'External API error', + details: error.response.data + }, { status: error.response.status }); + } else if (error.request) { + // The request was made but no response was received + return NextResponse.json({ + error: 'External API timeout', + message: 'The request timed out' + }, { status: 504 }); + } else { + // Something happened in setting up the request that triggered an Error + return NextResponse.json({ + error: 'Server error', + message: error.message + }, { status: 500 }); + } + } +} + +// Rate limiting helper function +function checkRateLimit(clientId: string): boolean { + const now = Date.now(); + + // Clean up expired entries + for (const [id, data] of rateLimiter.entries()) { + if (data.resetAt < now) { + rateLimiter.delete(id); + } + } + + // Get or create client rate limit data + let clientData = rateLimiter.get(clientId); + if (!clientData) { + clientData = { count: 0, resetAt: now + RATE_LIMIT_WINDOW }; + rateLimiter.set(clientId, clientData); + } else if (clientData.resetAt < now) { + // Reset if window expired + clientData.count = 0; + clientData.resetAt = now + RATE_LIMIT_WINDOW; + } + + // Check and increment + if (clientData.count >= MAX_REQUESTS_PER_WINDOW) { + return false; + } + + clientData.count++; + return true; +} diff --git a/app/search-offchain/TransactionContent.tsx b/app/search-offchain/TransactionContent.tsx index b1136f8..8bacb1a 100644 --- a/app/search-offchain/TransactionContent.tsx +++ b/app/search-offchain/TransactionContent.tsx @@ -6,11 +6,12 @@ import TransactionTableOffChain from "@/components/search-offchain/TransactionTa import Portfolio from "@/components/search/Portfolio" import { useSearchParams } from "next/navigation" import SearchBarOffChain from "@/components/search-offchain/SearchBarOffChain" +import ChainalysisDisplay from "@/components/Chainalysis" export default function Transactions() { const searchParams = useSearchParams() - const address = searchParams.get("address") + const address = searchParams?.get("address") ?? null return (
    @@ -20,11 +21,14 @@ export default function Transactions() { {address ? ( <>
    -
    - - -
    - + + + + +
    + +
    +
    diff --git a/app/search/TransactionContent.tsx b/app/search/TransactionContent.tsx index 0e33076..0b02bf3 100644 --- a/app/search/TransactionContent.tsx +++ b/app/search/TransactionContent.tsx @@ -22,9 +22,9 @@ const ETH_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; export default function Transactions() { const searchParams = useSearchParams() const router = useRouter() - const address = searchParams.get("address") - const networkParam = searchParams.get("network") || "mainnet" - const providerParam = searchParams.get("provider") || "etherscan" + const address = searchParams?.get("address") ?? null + const networkParam = searchParams?.get("network") ?? "mainnet" + const providerParam = searchParams?.get("provider") ?? "etherscan" const [network, setNetwork] = useState(networkParam) const [provider, setProvider] = useState(providerParam) const [pendingTxCount, setPendingTxCount] = useState(null) diff --git a/components/NFT/AnimatedNFTCard.tsx b/components/NFT/AnimatedNFTCard.tsx index 8853ddc..a48e841 100644 --- a/components/NFT/AnimatedNFTCard.tsx +++ b/components/NFT/AnimatedNFTCard.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'; -import { Info, ExternalLink } from 'lucide-react'; +import { ExternalLink } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { getChainColorTheme } from '@/lib/api/chainProviders'; import LazyImage from './LazyImage'; @@ -28,7 +28,6 @@ interface AnimatedNFTCardProps { export default function AnimatedNFTCard({ nft, onClick, index = 0, isVirtualized = false }: AnimatedNFTCardProps) { const [imageLoaded, setImageLoaded] = useState(false); - const [blurAmount, setBlurAmount] = useState(20); // For progressive loading blur effect const cardRef = useRef(null); // Chain-specific styling @@ -44,7 +43,7 @@ export default function AnimatedNFTCard({ nft, onClick, index = 0, isVirtualized damping: 15 }); const rotateY = useSpring(useTransform(x, [-100, 100], [-10, 10]), { - stiffness: 200, + stiffness: 200, damping: 15 }); @@ -62,15 +61,14 @@ export default function AnimatedNFTCard({ nft, onClick, index = 0, isVirtualized // Update shine opacity based on mouse position useEffect(() => { - const unsubscribeX = shineX.onChange(latestX => { + function updateShineOpacity() { + const latestX = shineX.get(); const latestY = shineY.get(); shineOpacity.set((latestX + latestY) / 8); - }); + } - const unsubscribeY = shineY.onChange(latestY => { - const latestX = shineX.get(); - shineOpacity.set((latestX + latestY) / 8); - }); + const unsubscribeX = shineX.on("change", updateShineOpacity); + const unsubscribeY = shineY.on("change", updateShineOpacity); return () => { unsubscribeX(); @@ -78,24 +76,7 @@ export default function AnimatedNFTCard({ nft, onClick, index = 0, isVirtualized }; }, [shineX, shineY, shineOpacity]); - // Progressive loading animation - useEffect(() => { - if (imageLoaded) { - const timer = setInterval(() => { - setBlurAmount((prev) => { - if (prev <= 0) { - clearInterval(timer); - return 0; - } - return prev - 4; - }); - }, 50); - - return () => clearInterval(timer); - } - }, [imageLoaded]); - - function handleMouseMove(e: React.MouseEvent) { + function handleMouseMove(e: React.MouseEvent) { if (cardRef.current) { const rect = cardRef.current.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; @@ -235,14 +216,14 @@ export default function AnimatedNFTCard({ nft, onClick, index = 0, isVirtualized {/* Network Badge - Positioned absolutely top-right */}
    -
    +
    @@ -251,63 +232,51 @@ export default function AnimatedNFTCard({ nft, onClick, index = 0, isVirtualized
    - {/* Card Content with Shine Effect */} -
    - + setImageLoaded(true)} + onError={() => {/* Error handled inside LazyImage */}} /> + + {/* Chain indicator corner decoration */} +
    +
    - {/* NFT Image with progressive loading */} -
    - {/* Use our optimized LazyImage component */} - setImageLoaded(true)} - onError={() => {/* Error handled inside LazyImage */}} - /> - - {/* Chain indicator corner decoration */} -
    + {/* Info Section */} +
    +

    + {nft.name || `NFT #${nft.tokenId}`} +

    + +
    +

    + ID: {parseInt(nft.tokenId, 16) ? parseInt(nft.tokenId, 16).toString() : nft.tokenId} +

    +
    - - {/* Info Section */} -
    -

    - {nft.name || `NFT #${nft.tokenId}`} -

    - -
    -

    - ID: {parseInt(nft.tokenId, 16) ? parseInt(nft.tokenId, 16).toString() : nft.tokenId} -

    - -
    - - {/* Attributes */} -
    - {nft.attributes?.slice(0, 3).map((attr, i) => ( - - {attr.trait_type === 'Network' ? null : `${attr.trait_type}: ${attr.value}`} - - ))} -
    + + {/* Attributes */} +
    + {nft.attributes?.slice(0, 3).map((attr, i) => ( + + {attr.trait_type === 'Network' ? null : `${attr.trait_type}: ${attr.value}`} + + ))}
    ); -} +} \ No newline at end of file diff --git a/components/NFT/FeaturedSpotlight.tsx b/components/NFT/FeaturedSpotlight.tsx index 89000e3..9e81f18 100644 --- a/components/NFT/FeaturedSpotlight.tsx +++ b/components/NFT/FeaturedSpotlight.tsx @@ -31,7 +31,7 @@ const spotlights: NFTSpotlight[] = [ id: 'pancake-squad', name: 'Pancake Squad', description: 'A collection of 10,000 unique, cute, and sometimes fierce PancakeSwap bunny NFTs that serve as your membership to the Pancake Squad.', - image: 'https://assets.pancakeswap.finance/pancakeSquad/header.png', + image: 'https://i.seadn.io/s/primary-drops/0xc291cc12018a6fcf423699bce985ded86bac47cb/33406336:about:media:6f541d5a-5309-41ad-8f73-74f092ed1314.png?auto=format&dpr=1&w=1200', chain: '0x38', // BNB Chain contractAddress: '0xdcbcf766dcd33a7a8abe6b01a8b0e44a006c4ac1', artist: 'PancakeSwap' @@ -235,4 +235,4 @@ export default function FeaturedSpotlight() {
    ); -} +} \ No newline at end of file diff --git a/components/NFT/NetworkSelector.tsx b/components/NFT/NetworkSelector.tsx index 655a7b4..28be75c 100644 --- a/components/NFT/NetworkSelector.tsx +++ b/components/NFT/NetworkSelector.tsx @@ -97,10 +97,11 @@ export default function NetworkSelector({ >
    {network.name}
    {network.name} @@ -128,8 +129,9 @@ export default function NetworkSelector({ {n.name}
    diff --git a/components/NFT/PaginatedNFTGrid.tsx b/components/NFT/PaginatedNFTGrid.tsx index f4162db..57ede92 100644 --- a/components/NFT/PaginatedNFTGrid.tsx +++ b/components/NFT/PaginatedNFTGrid.tsx @@ -1,389 +1,346 @@ -import { useState, useEffect, useCallback } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Search, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'; +import React, { useState, useEffect, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { Loader2, ChevronLeft, ChevronRight, AlertCircle } from 'lucide-react'; +import { fetchPaginatedNFTs } from '@/lib/api/nftService'; import AnimatedNFTCard from './AnimatedNFTCard'; -import { - fetchCollectionNFTs, - estimateCollectionMemoryUsage, - getNFTIndexingStatus, - fetchPaginatedNFTs -} from '@/lib/api/nftService'; import { getChainColorTheme } from '@/lib/api/chainProviders'; -import { Progress } from '@/components/ui/progress'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { useToast } from '@/hooks/use-toast'; import { Button } from '@/components/ui/button'; import { Pagination, PaginationContent, + PaginationEllipsis, PaginationItem, PaginationLink, - PaginationEllipsis, PaginationNext, PaginationPrevious, -} from '@/components/ui/pagination'; +} from "@/components/ui/pagination"; + +interface NFT { + id: string; + tokenId: string; + name: string; + description?: string; + imageUrl: string; + chain: string; + attributes?: Array<{ + trait_type: string; + value: string; + }>; +} interface PaginatedNFTGridProps { contractAddress: string; chainId: string; + searchQuery?: string; sortBy?: string; sortDirection?: 'asc' | 'desc'; - searchQuery?: string; attributes?: Record; viewMode?: 'grid' | 'list'; - onNFTClick?: (nft: any) => void; + onNFTClick?: (nft: NFT) => void; itemsPerPage?: number; defaultPage?: number; onPageChange?: (page: number) => void; } -export default function PaginatedNFTGrid({ +const PaginatedNFTGrid: React.FC = ({ contractAddress, chainId, + searchQuery = '', sortBy = 'tokenId', sortDirection = 'asc', - searchQuery = '', attributes = {}, viewMode = 'grid', onNFTClick, - itemsPerPage = 20, // Reduced to exactly 20 for optimal API usage + itemsPerPage = 20, defaultPage = 1, onPageChange -}: PaginatedNFTGridProps) { - // State for pagination and data - const [nfts, setNfts] = useState([]); - const [totalCount, setTotalCount] = useState(0); - const [totalPages, setTotalPages] = useState(1); - const [currentPage, setCurrentPage] = useState(defaultPage); +}) => { + const [nfts, setNfts] = useState([]); const [loading, setLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(defaultPage); + const [totalPages, setTotalPages] = useState(1); + const [totalItems, setTotalItems] = useState(0); const [error, setError] = useState(null); - const [indexingStatus, setIndexingStatus] = useState<{ - status: 'completed' | 'in_progress' | 'not_started'; - progress: number; - }>({ status: 'completed', progress: 100 }); + const [isLastPage, setIsLastPage] = useState(false); + const [pageHistory, setPageHistory] = useState<{[key: number]: boolean}>({}); - // For better UX when loading new pages - const [fadeState, setFadeState] = useState("in"); + const { toast } = useToast(); - // Get chain theme for styling + // Get theme colors based on chain const chainTheme = getChainColorTheme(chainId); - // Fetch NFTs for the current page - const fetchNFTsForPage = useCallback(async (page: number) => { - if (!contractAddress) return; - - setLoading(true); - setFadeState("out"); - - try { - // Check indexing status - const indexing = await getNFTIndexingStatus(contractAddress, chainId); - setIndexingStatus(indexing); - - // Use our optimized paginated fetch function - const result = await fetchPaginatedNFTs( - contractAddress, - chainId, - page, - itemsPerPage, - sortBy, - sortDirection, - searchQuery, - attributes - ); - - // Calculate total pages - const pages = Math.ceil(result.totalCount / itemsPerPage); - console.log(`DEBUG: Total count ${result.totalCount}, items per page ${itemsPerPage}, total pages: ${pages}`); - - setNfts(result.nfts); - setTotalCount(result.totalCount); - setTotalPages(pages > 0 ? pages : 1); + // Load NFTs when parameters change + useEffect(() => { + const loadNFTs = async () => { + setLoading(true); setError(null); - // Fade in the new content - setTimeout(() => { - setFadeState("in"); - }, 100); - } catch (err) { - console.error('Error fetching NFTs:', err); - setError('Failed to load NFTs. Please try again.'); - setFadeState("in"); - } finally { - setLoading(false); - } - }, [contractAddress, chainId, itemsPerPage, sortBy, sortDirection, searchQuery, attributes]); - - // Load initial data - useEffect(() => { - fetchNFTsForPage(currentPage); - }, [currentPage, fetchNFTsForPage]); - - // Update current page if default page changes from parent - useEffect(() => { - if (defaultPage !== currentPage) { - setCurrentPage(defaultPage); - } - }, [defaultPage]); + try { + const result = await fetchPaginatedNFTs( + contractAddress, + chainId, + currentPage, + itemsPerPage, + sortBy, + sortDirection, + searchQuery, + attributes + ); + + // Check if this is the last page based on NFT count + const isEmptyPage = result.nfts.length === 0; + const isPartialPage = result.nfts.length < itemsPerPage; + const calculatedLastPage = isEmptyPage || isPartialPage; + + setNfts(result.nfts); + setTotalPages(result.totalPages || Math.ceil(result.totalCount / itemsPerPage)); + setTotalItems(result.totalCount); + setIsLastPage(calculatedLastPage); + + // Record this page in history to optimize navigation + setPageHistory(prev => ({...prev, [currentPage]: true})); + + // Log for debugging + console.log(`Loaded page ${currentPage} with ${result.nfts.length} NFTs. Total: ${result.totalCount}`); + console.log(`Is last page: ${calculatedLastPage}, Total pages: ${result.totalPages}`); + } catch (err) { + console.error('Error loading NFTs:', err); + setError('Failed to load NFTs. Please try again.'); + toast({ + title: 'Error', + description: 'Failed to load NFTs. Please try again.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + loadNFTs(); + }, [ + contractAddress, + chainId, + currentPage, + itemsPerPage, + sortBy, + sortDirection, + searchQuery, + JSON.stringify(attributes), + toast + ]); - // Handle explicit page navigation - const handlePageChange = (page: number) => { - // Validate page range - if (page < 1 || page > totalPages) return; + // Handle page change with improved boundary logic + const handlePageChange = useCallback((page: number) => { + if (page < 1 || (isLastPage && page > currentPage) || page === currentPage) return; - // Scroll to top of grid - window.scrollTo({ top: 0, behavior: 'smooth' }); + // Allow going to next page even if we haven't calculated total pages yet + // This handles collections where we don't know the exact total + if (page > totalPages && !pageHistory[page] && !isLastPage) { + // Allow exploration to continue + console.log("Exploring beyond known pages:", page); + } else if (page > totalPages) { + console.log("Attempted to go beyond last page:", page); + return; + } - // Change page setCurrentPage(page); - - // Notify parent if callback provided if (onPageChange) { onPageChange(page); } - }; - - // Generate page numbers to display - const getPageNumbers = () => { - const pages = []; - // Always show first page - pages.push(1); + // Scroll to top of grid + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, [currentPage, totalPages, isLastPage, pageHistory, onPageChange]); + + // Generate pagination items with enhanced logic + const renderPaginationItems = useCallback(() => { + const items = []; + const maxVisible = 5; // Maximum number of page buttons to show - // Middle pages - const rangeStart = Math.max(2, currentPage - 1); - const rangeEnd = Math.min(totalPages - 1, currentPage + 1); + let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2)); + const endPage = Math.min(totalPages, startPage + maxVisible - 1); - // Add ellipsis after first page if needed - if (rangeStart > 2) { - pages.push(-1); // -1 represents ellipsis + // Adjust start if we're near the end + if (endPage - startPage + 1 < maxVisible) { + startPage = Math.max(1, endPage - maxVisible + 1); } - // Add middle pages - for (let i = rangeStart; i <= rangeEnd; i++) { - pages.push(i); + // Add first page if not included + if (startPage > 1) { + items.push( + + handlePageChange(1)}>1 + + ); + + // Add ellipsis if there's a gap + if (startPage > 2) { + items.push( + + + + ); + } } - // Add ellipsis before last page if needed - if (rangeEnd < totalPages - 1) { - pages.push(-2); // -2 represents ellipsis + // Add page numbers + for (let i = startPage; i <= endPage; i++) { + items.push( + + handlePageChange(i)} + className={currentPage === i ? chainTheme.backgroundClass : ''} + > + {i} + + + ); } - // Always show last page if there's more than one page - if (totalPages > 1) { - pages.push(totalPages); + // Add last page if not included + if (endPage < totalPages) { + // Add ellipsis if there's a gap + if (endPage < totalPages - 1) { + items.push( + + + + ); + } + + items.push( + + handlePageChange(totalPages)}> + {totalPages} + + + ); } - return pages; - }; + return items; + }, [currentPage, totalPages, chainTheme, handlePageChange]); - // Empty state - if (!loading && nfts.length === 0) { - return ( -
    - -

    No NFTs found for this collection.

    -

    Try adjusting your search or filters

    -
    - ); - } + // Handle NFT click + const handleNFTClick = (nft: NFT) => { + if (onNFTClick) { + onNFTClick(nft); + } + }; - // Loading state for initial load - if (loading && nfts.length === 0) { - return ( -
    -
    -
    - Loading NFTs... -
    -
    - Estimated size: - - - - - {estimateCollectionMemoryUsage(totalCount || 1000)} - - - -

    Estimated memory usage for full collection

    -
    -
    -
    -
    -
    - - {/* Loading skeleton */} -
    - {Array.from({ length: itemsPerPage }).map((_, index) => ( -
    - ))} -
    -
    - ); - } + // Calculate item range for display + const startItem = totalItems > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0; + const endItem = Math.min(currentPage * itemsPerPage, totalItems); - // Error state - if (error && nfts.length === 0) { - return ( -
    - -

    {error}

    - -
    - ); - } - return ( -
    - {/* Collection indexing status */} - {indexingStatus.status !== 'completed' && ( -
    -
    - Collection indexing in progress - {Math.round(indexingStatus.progress)}% complete -
    - -

    - This collection is still being indexed. More NFTs will appear as indexing completes. -

    +
    + {/* Loading state */} + {loading && ( +
    +
    )} - {/* Stats bar with debug info */} -
    -
    - Showing page {currentPage} of {totalPages} ({nfts.length} of {totalCount.toLocaleString()} total items) -
    - - {/* Memory usage estimate */} - - - - - {estimateCollectionMemoryUsage(totalCount)} - - - -

    Estimated memory usage for full collection

    -
    -
    -
    -
    - - {/* Loading overlay for page changes */} - {loading && nfts.length > 0 && ( -
    -
    + {/* Error message */} + {error && !loading && ( +
    + +

    {error}

    +
    )} - - {/* NFT Grid with fade transition */} -
    -
    - - {nfts.map((nft, index) => ( - - onNFTClick && onNFTClick(nft)} - /> - - ))} - -
    -
    - {/* Enhanced Navigation Controls - Always Visible */} -
    -
    -
    -
    -
    - Page {currentPage} of {totalPages} -
    - {totalPages > 1 && ( -
    - {(currentPage - 1) * itemsPerPage + 1} - {Math.min(currentPage * itemsPerPage, totalCount)} of {totalCount} -
    - )} + {/* NFT Grid */} + {!loading && !error && ( + + {nfts.length > 0 ? ( + nfts.map((nft, index) => ( + handleNFTClick(nft)} + index={index} + /> + )) + ) : ( +
    +

    No NFTs found for this collection.

    - -
    -
    - {/* Previous button - always visible */} + )} + + )} + + {/* Enhanced Pagination Controls - Always visible when we have items */} + {!loading && nfts.length > 0 && ( +
    + {/* Items count info - Now above pagination for better visibility */} +
    + {totalItems > 0 ? ( + <>Showing {startItem}-{endItem} of {totalItems.toLocaleString()} items + ) : ( + <>No items to display + )} +
    + +
    + + + {/* Enhanced Previous Button */} - {/* Page numbers - enhanced visibility */} - {totalPages > 1 && getPageNumbers().map((pageNum, i) => ( - pageNum < 0 ? ( - - … - - ) : ( - - ) - ))} + {renderPaginationItems()} - {/* Next button - always visible */} + {/* Enhanced Next Button */} -
    -
    + + +
    + + {/* Page indicator for small screens */} +
    + Page {currentPage} of {totalPages} + {isLastPage ? " (Last Page)" : ""}
    -
    - - {/* Optimized loading note */} -
    - Optimized pagination with 20 items per page to minimize API usage -
    + )}
    ); -} +}; + +export default PaginatedNFTGrid; \ No newline at end of file diff --git a/components/NFT/VirtualizedNFTGrid.tsx b/components/NFT/VirtualizedNFTGrid.tsx index 1e12206..87b10ce 100644 --- a/components/NFT/VirtualizedNFTGrid.tsx +++ b/components/NFT/VirtualizedNFTGrid.tsx @@ -1,179 +1,198 @@ -import { useRef, useState, useEffect, useCallback, useMemo } from 'react'; -import { useInView } from 'react-intersection-observer'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Loader2, Search, AlertCircle } from 'lucide-react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Loader2 } from 'lucide-react'; import AnimatedNFTCard from './AnimatedNFTCard'; -import { - fetchNFTsWithOptimizedCursor, +import { + fetchNFTsWithOptimizedCursor, fetchNFTsWithProgressiveLoading, - estimateCollectionMemoryUsage + estimateCollectionMemoryUsage, + clearSpecificCollectionCache } from '@/lib/api/nftService'; import { getChainColorTheme } from '@/lib/api/chainProviders'; -import { Progress } from '@/components/ui/progress'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { Button } from '@/components/ui/button'; +import { useToast } from '@/hooks/use-toast'; + +interface NFT { + id: string; + tokenId: string; + name: string; + description?: string; + imageUrl: string; + chain: string; + attributes?: Array<{ + trait_type: string; + value: string; + }>; + isPlaceholder?: boolean; +} interface VirtualizedNFTGridProps { contractAddress: string; chainId: string; + searchQuery?: string; sortBy?: string; sortDirection?: 'asc' | 'desc'; - searchQuery?: string; attributes?: Record; viewMode?: 'grid' | 'list'; - onNFTClick?: (nft: any) => void; - itemsPerPage?: number; - maxItems?: number; // Optional limit to total items + onNFTClick?: (nft: NFT) => void; + progressiveLoading?: boolean; + batchSize?: number; + estimatedRowHeight?: number; + onLoadingComplete?: () => void; } export default function VirtualizedNFTGrid({ contractAddress, chainId, + searchQuery = '', sortBy = 'tokenId', sortDirection = 'asc', - searchQuery = '', attributes = {}, viewMode = 'grid', onNFTClick, - itemsPerPage = 24, - maxItems + progressiveLoading = true, + batchSize = 50, + estimatedRowHeight = 300, + onLoadingComplete }: VirtualizedNFTGridProps) { - // State for different loading approaches - const [nfts, setNfts] = useState([]); + // State for grid + const [nfts, setNfts] = useState([]); const [totalCount, setTotalCount] = useState(0); - const [loadedCount, setLoadedCount] = useState(0); - const [progress, setProgress] = useState(0); - const [loading, setLoading] = useState(true); - const [initialLoading, setInitialLoading] = useState(true); - const [error, setError] = useState(null); - const [nextCursor, setNextCursor] = useState(undefined); - const [hasMore, setHasMore] = useState(true); - const [useProgressiveLoading, setUseProgressiveLoading] = useState(false); - const [progressiveLoadingStarted, setProgressiveLoadingStarted] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [cursor, setCursor] = useState(undefined); + const [hasNextPage, setHasNextPage] = useState(true); + const [loadingError, setLoadingError] = useState(null); + const [loadingProgress, setLoadingProgress] = useState(0); + const [windowWidth, setWindowWidth] = useState(0); - // Refs for optimization - const loadingMoreRef = useRef(false); - const gridRef = useRef(null); + // Refs const parentRef = useRef(null); - const containerSize = useRef({ width: 0, height: 0 }); + const loadMoreRef = useRef(null); + const gridContentRef = useRef(null); + const { toast } = useToast(); - // Get chain theme for styling + // Chain theme for styling const chainTheme = getChainColorTheme(chainId); - // Intersection observer for infinite loading - const { ref: loadMoreRef, inView } = useInView({ - threshold: 0, - rootMargin: '400px 0px', // Load more before user reaches bottom - }); - - // Calculate grid dimensions - const calculateGridDimensions = useCallback(() => { - if (!parentRef.current) return; - - const container = parentRef.current; - const containerWidth = container.clientWidth; - - let columns: number; - if (viewMode === 'list') { - columns = 1; - } else { - // Responsive column count - if (containerWidth >= 1280) columns = 4; - else if (containerWidth >= 768) columns = 3; - else if (containerWidth >= 640) columns = 2; - else columns = 1; - } + // Calculate columns based on view mode and window width + const getColumnCount = useCallback(() => { + if (!windowWidth) return viewMode === 'grid' ? 4 : 2; - // Estimate item width and height - const itemWidth = Math.floor(containerWidth / columns); - const itemHeight = viewMode === 'list' ? 120 : itemWidth; // List items are shorter + if (viewMode === 'list') return 1; + if (windowWidth < 640) return 1; + if (windowWidth < 768) return 2; + if (windowWidth < 1024) return 3; + return 4; + }, [viewMode, windowWidth]); + + // Calculate window width on mount and resize + useEffect(() => { + const handleResize = () => { + setWindowWidth(window.innerWidth); + }; - return { columns, itemWidth, itemHeight }; - }, [viewMode]); - - // Virtualization setup for grid layout - const dimensions = useMemo(() => calculateGridDimensions(), [calculateGridDimensions]); + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Calculate rows for virtualizer based on items and columns + const columnCount = getColumnCount(); + const rowCount = Math.ceil(nfts.length / columnCount); - // Grid virtualizer + // Set up virtualizer const rowVirtualizer = useVirtualizer({ - count: Math.ceil(nfts.length / (dimensions?.columns || 1)), + count: hasNextPage ? rowCount + 1 : rowCount, // +1 for loading more row getScrollElement: () => parentRef.current, - estimateSize: useCallback(() => dimensions?.itemHeight || 300, [dimensions]), + estimateSize: () => estimatedRowHeight, overscan: 5, }); - // Decide whether to use progressive loading based on collection size - useEffect(() => { - if (totalCount > 500 && !progressiveLoadingStarted && !useProgressiveLoading) { - // For large collections, suggest progressive loading - const shouldUseProgressive = totalCount > 2000; - setUseProgressiveLoading(shouldUseProgressive); - } - }, [totalCount, progressiveLoadingStarted, useProgressiveLoading]); - - // Handle window resize + // Load NFTs using the appropriate loading strategy useEffect(() => { - const handleResize = () => { - if (parentRef.current) { - containerSize.current = { - width: parentRef.current.clientWidth, - height: parentRef.current.clientHeight - }; + setIsLoading(true); + setLoadingError(null); + setLoadingProgress(0); + + // Either use progressive loading or cursor-based loading + const loadNFTs = async () => { + try { + if (progressiveLoading) { + // Progressive loading loads all NFTs in batches + const result = await fetchNFTsWithProgressiveLoading( + contractAddress, + chainId, + { + batchSize, + initialPageSize: batchSize, + sortBy, + sortDirection, + searchQuery, + attributes, + onProgress: (loaded: number, total: number) => { + setLoadingProgress(Math.round((loaded / total) * 100)); + } + } + ); + + setNfts(result.nfts); + setTotalCount(result.totalCount); + setHasNextPage(!!result.hasMoreBatches); + setLoadingProgress(result.progress); + } else { + // First load with cursor-based pagination + const result = await fetchNFTsWithOptimizedCursor( + contractAddress, + chainId, + '1', // Start with first page + batchSize, + sortBy, + sortDirection, + searchQuery, + attributes + ); + + setNfts(result.nfts); + setTotalCount(result.totalCount); + setCursor(result.nextCursor); + setHasNextPage(!!result.nextCursor); + setLoadingProgress(result.progress); + } + + setIsLoading(false); + if (onLoadingComplete) onLoadingComplete(); + } catch (error) { + console.error('Error loading NFTs:', error); + setLoadingError('Failed to load NFTs. Please try again.'); + setIsLoading(false); + toast({ + title: 'Error', + description: 'Failed to load NFTs. Please try again.', + variant: 'destructive', + }); } }; - handleResize(); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - // Load initial data with optimized cursor approach - const loadInitialData = useCallback(async () => { - setInitialLoading(true); - setLoading(true); - setError(null); + loadNFTs(); - try { - const result = await fetchNFTsWithOptimizedCursor( - contractAddress, - chainId, - undefined, // No cursor for initial load - itemsPerPage, - sortBy, - sortDirection, - searchQuery, - attributes - ); - - setNfts(result.nfts); - setTotalCount(result.totalCount); - setLoadedCount(result.loadedCount); - setProgress(result.progress); - setNextCursor(result.nextCursor); - setHasMore(!!result.nextCursor); - } catch (err) { - setError('Failed to load NFTs. Please try again.'); - console.error('Error loading NFTs:', err); - } finally { - setInitialLoading(false); - setLoading(false); - } - }, [contractAddress, chainId, itemsPerPage, sortBy, sortDirection, searchQuery, attributes]); + // Cleanup function to abort any in-progress loads when filters change + return () => { + // Could implement an abort controller here if needed + }; + }, [contractAddress, chainId, searchQuery, sortBy, sortDirection, JSON.stringify(attributes)]); - // Load more data using the cursor-based approach - const loadMoreData = useCallback(async () => { - if (!nextCursor || loadingMoreRef.current || !hasMore) return; - - loadingMoreRef.current = true; - setLoading(true); + // Load more NFTs when scrolling to the end (for cursor-based pagination) + const loadMoreNFTs = useCallback(async () => { + if (!hasNextPage || isLoading || progressiveLoading) return; try { + setIsLoading(true); + const result = await fetchNFTsWithOptimizedCursor( contractAddress, chainId, - nextCursor, - itemsPerPage, + cursor, + batchSize, sortBy, sortDirection, searchQuery, @@ -181,262 +200,218 @@ export default function VirtualizedNFTGrid({ ); setNfts(prev => [...prev, ...result.nfts]); - setNextCursor(result.nextCursor); - setHasMore(!!result.nextCursor); - setLoadedCount(result.loadedCount); - setProgress(result.progress); - } catch (err) { - console.error('Error loading more NFTs:', err); - } finally { - loadingMoreRef.current = false; - setLoading(false); + setCursor(result.nextCursor); + setHasNextPage(!!result.nextCursor); + setLoadingProgress(result.progress); + setIsLoading(false); + } catch (error) { + console.error('Error loading more NFTs:', error); + setIsLoading(false); + toast({ + title: 'Error', + description: 'Failed to load more NFTs. Please try again.', + variant: 'destructive', + }); } - }, [nextCursor, hasMore, contractAddress, chainId, itemsPerPage, sortBy, sortDirection, searchQuery, attributes]); + }, [ + hasNextPage, + isLoading, + cursor, + contractAddress, + chainId, + batchSize, + sortBy, + sortDirection, + searchQuery, + attributes + ]); - // Start progressive loading to load entire collection - const startProgressiveLoading = useCallback(async () => { - setProgressiveLoadingStarted(true); + // Load more when the virtualized rows include the loading row + useEffect(() => { + const range = rowVirtualizer.range; + if (!range) return; - try { - // Start the progressive loading process - const onProgressUpdate = (loaded: number, total: number) => { - setLoadedCount(loaded); - setProgress(Math.min(100, (loaded / total) * 100)); - }; - - const result = await fetchNFTsWithProgressiveLoading( - contractAddress, - chainId, - { - batchSize: 100, - maxBatches: maxItems ? Math.ceil(maxItems / 100) : 100, - initialPage: 1, - initialPageSize: nfts.length > 0 ? nfts.length : itemsPerPage, - sortBy, - sortDirection, - searchQuery, - attributes, - onProgress: onProgressUpdate - } - ); - - setNfts(result.nfts); - setTotalCount(result.totalCount); - setHasMore(result.hasMoreBatches); - setProgress(result.progress); - } catch (err) { - console.error('Error in progressive loading:', err); + const lastRow = range.endIndex; + const totalRows = rowCount; + + // If we're within 3 rows of the end and there are more items to load, load more + if (!progressiveLoading && !isLoading && hasNextPage && lastRow >= totalRows - 3) { + loadMoreNFTs(); } - }, [contractAddress, chainId, itemsPerPage, maxItems, nfts.length, sortBy, sortDirection, searchQuery, attributes]); + }, [ + rowVirtualizer.range?.endIndex, + rowCount, + isLoading, + hasNextPage, + loadMoreNFTs, + progressiveLoading + ]); - // Load initial data on mount and when dependencies change - useEffect(() => { - loadInitialData(); - }, [loadInitialData]); + // Clear cache for this collection (useful for admin or debug) + const handleClearCache = useCallback(() => { + clearSpecificCollectionCache(contractAddress, chainId); + toast({ + title: 'Cache Cleared', + description: 'The cache for this collection has been cleared.', + }); + }, [contractAddress, chainId, toast]); - // Trigger load more when in view - useEffect(() => { - if (inView && !initialLoading && hasMore && !loading && !progressiveLoadingStarted) { - loadMoreData(); - } - }, [inView, initialLoading, hasMore, loadMoreData, loading, progressiveLoadingStarted]); + // Choose the right item height based on view mode + const getItemHeight = () => { + if (viewMode === 'list') return 120; + return windowWidth < 640 ? 280 : 320; + }; - // Apply maximum items limit if specified - useEffect(() => { - if (maxItems && nfts.length > maxItems) { - setNfts(prev => prev.slice(0, maxItems)); - setHasMore(false); - } - }, [nfts, maxItems]); + // Check if NFTs are being filtered + const isFiltered = !!searchQuery || Object.keys(attributes).length > 0; - // Render virtualized grid items - const renderVirtualizedItems = () => { - const { columns = 1 } = dimensions || {}; - - return ( + // Handle NFT click + const handleNFTClick = (nft: NFT) => { + if (onNFTClick) onNFTClick(nft); + }; + + // Get memory usage estimate for debugging + const memoryEstimate = estimateCollectionMemoryUsage(totalCount); + + return ( +
    + {/* Progress indicator for progressive loading */} + {progressiveLoading && loadingProgress < 100 && ( +
    +
    +
    + Loading NFTs: {loadingProgress}% ({nfts.length} of {totalCount}) +
    +
    + Est. memory: {memoryEstimate} +
    +
    +
    +
    +
    +
    + )} + + {/* Main virtualized grid container */}
    + {/* Virtualizer inner container */}
    {rowVirtualizer.getVirtualItems().map(virtualRow => { - const rowStartIndex = virtualRow.index * columns; + const rowIsLoaderRow = hasNextPage && virtualRow.index === rowCount; + + // If this is the loading indicator row + if (rowIsLoaderRow) { + return ( +
    + {isLoading ? ( +
    + + Loading more NFTs... +
    + ) : ( +
    + {hasNextPage ? 'Scroll to load more' : 'No more NFTs to load'} +
    + )} +
    + ); + } + + // Regular row of NFTs + const rowStartIndex = virtualRow.index * columnCount; + const rowEndIndex = Math.min(rowStartIndex + columnCount, nfts.length); + const rowNFTs = nfts.slice(rowStartIndex, rowEndIndex); return (
    - {Array.from({ length: columns }).map((_, colIndex) => { - const nftIndex = rowStartIndex + colIndex; - const nft = nfts[nftIndex]; - - if (!nft) return
    ; - - return ( -
    - onNFTClick && onNFTClick(nft)} - isVirtualized={true} - /> -
    - ); - })} + {rowNFTs.map((nft, idx) => ( +
    + handleNFTClick(nft)} + index={rowStartIndex + idx} + isVirtualized={true} + /> +
    + ))} + + {/* Fill empty cells in last row */} + {rowNFTs.length < columnCount && !isFiltered && ( + Array.from({ length: columnCount - rowNFTs.length }).map((_, idx) => ( +
    + )) + )}
    ); })}
    - ); - }; - - // Empty state - if (!initialLoading && nfts.length === 0) { - return ( -
    - -

    No NFTs found for this collection.

    -

    Try adjusting your search or filters

    -
    - ); - } - - // Loading state for initial load - if (initialLoading) { - return ( -
    - {Array.from({ length: itemsPerPage }).map((_, index) => ( -
    - ))} -
    - ); - } - - // Error state - if (error) { - return ( -
    - -

    {error}

    - -
    - ); - } - - return ( -
    - {/* Progress indicator */} -
    -
    - - Showing {nfts.length} of {totalCount.toLocaleString()} items - - - {/* Memory usage estimate */} - - - - - {estimateCollectionMemoryUsage(totalCount)} - - - -

    Estimated memory usage for full collection

    -
    -
    -
    -
    - - {/* Progressive loading button for large collections */} - {totalCount > 500 && !progressiveLoadingStarted && ( - - )} -
    - {/* Loading progress bar */} - {(loading || progress > 0 && progress < 100) && ( -
    - -
    - {loadedCount.toLocaleString()} loaded - {Math.round(progress)}% -
    -
    - )} - - {/* NFT Grid with virtualization for large collections */} - {nfts.length > 100 ? ( - renderVirtualizedItems() - ) : ( -
    - - {nfts.map((nft, index) => ( - - onNFTClick && onNFTClick(nft)} - /> - - ))} - + {/* Error message */} + {loadingError && ( +
    +

    {loadingError}

    +
    )} - {/* Load more trigger */} - {hasMore && !progressiveLoadingStarted && ( -
    -
    + {/* Empty state */} + {!isLoading && nfts.length === 0 && ( +
    +
    +

    No NFTs Found

    +

    + {isFiltered + ? 'No NFTs match your current filters. Try adjusting your search or filters.' + : 'This collection appears to be empty or still loading.'} +

    + {isFiltered && ( + + )} +
    )}
    diff --git a/components/portfolio/NFTsCard.tsx b/components/portfolio/NFTsCard.tsx index 5f1218f..f66ad51 100644 --- a/components/portfolio/NFTsCard.tsx +++ b/components/portfolio/NFTsCard.tsx @@ -49,6 +49,7 @@ const NFTsCard: React.FC = ({ nfts, isLoading }) => {
    + {/* eslint-disable-next-line jsx-a11y/alt-text */}

    NFT Collection

    @@ -89,6 +90,7 @@ const NFTsCard: React.FC = ({ nfts, isLoading }) => { /> ) : (
    + {/* eslint-disable-next-line jsx-a11y/alt-text */}
    )} @@ -115,6 +117,7 @@ const NFTsCard: React.FC = ({ nfts, isLoading }) => { )) ) : (
    + {/* eslint-disable-next-line jsx-a11y/alt-text */}

    No NFTs found for this wallet @@ -178,6 +181,7 @@ const NFTsCard: React.FC = ({ nfts, isLoading }) => { /> ) : (

    + {/* eslint-disable-next-line jsx-a11y/alt-text */}
    )} diff --git a/components/search-offchain/SearchBarOffChain.tsx b/components/search-offchain/SearchBarOffChain.tsx index 65b9b18..4f0315e 100644 --- a/components/search-offchain/SearchBarOffChain.tsx +++ b/components/search-offchain/SearchBarOffChain.tsx @@ -4,87 +4,264 @@ import { useState } from "react" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { useRouter } from "next/navigation" -import { Search,X } from "lucide-react" +import { Search, X, Globe, AlertTriangle } from "lucide-react" import { LoadingScreen } from "@/components/loading-screen" +import Neo4jIcon from "@/components/icons/Neo4jIcon" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Label } from "@/components/ui/label" +import { toast } from "sonner" -export default function SearchBarOffChain() { +export type NetworkType = "mainnet" | "optimism" | "arbitrum" +export type ProviderType = "etherscan" | "infura" + +// Ethereum address validation regex pattern +const ETH_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; + +export default function SearchBar() { const [address, setAddress] = useState("") - const router = useRouter() const [isLoading, setIsLoading] = useState(false) - const [searchType, setSearchType] = useState<"onchain" | "offchain">("offchain"); + const [addressError, setAddressError] = useState(null) + const router = useRouter() + const [searchType, setSearchType] = useState<"onchain" | "offchain">("offchain") + const [network, setNetwork] = useState("mainnet") + const [provider, setProvider] = useState("etherscan") + + // Validate Ethereum address + const validateAddress = (addr: string): boolean => { + if (!addr) return false; + + // For on-chain searches, validate Ethereum address format + if (searchType === "onchain") { + if (!ETH_ADDRESS_REGEX.test(addr)) { + setAddressError("Invalid Ethereum address format. Must start with 0x followed by 40 hex characters."); + return false; + } + } else { + // For off-chain searches, validate Neo4j ID format + if (addr.length < 3) { + setAddressError("Neo4j identifier must be at least 3 characters"); + return false; + } + } + + setAddressError(null); + return true; + }; + + // Get available networks based on selected provider + const getAvailableNetworks = () => { + if (provider === "infura") { + return [ + { value: "mainnet", label: "Ethereum Mainnet" }, + { value: "optimism", label: "Optimism" }, + { value: "arbitrum", label: "Arbitrum" }, + ] + } else { + // Default Etherscan only supports Ethereum mainnet + return [ + { value: "mainnet", label: "Ethereum Mainnet" }, + ] + } + } + + // When provider changes, reset network if it's not available in the new provider + const handleProviderChange = (newProvider: ProviderType) => { + setProvider(newProvider) + + // Get available networks for the new provider + const availableNetworks = getAvailableNetworks().map(net => net.value) + + // Check if current network is available in the new provider + if (!availableNetworks.includes(network)) { + // If not, set to the first available network + setNetwork(availableNetworks[0] as NetworkType) + } + } + const handleSearch = async (e: React.FormEvent) => { e.preventDefault() - if (!address.trim()) return; + if (!address.trim()) return + + // Validate address before proceeding + if (!validateAddress(address.trim())) { + toast.error("Invalid address format", { + description: addressError || "Please check the address format and try again.", + action: { + label: 'Learn More', + onClick: () => window.open('https://ethereum.org/en/developers/docs/intro-to-ethereum/#ethereum-accounts', '_blank'), + } + }); + return; + } - setIsLoading(true); + setIsLoading(true) try { - // Giả lập thời gian tải (có thể thay bằng API call thực tế) - await new Promise(resolve => setTimeout(resolve, 2500)); + // Simulate loading time + await new Promise(resolve => setTimeout(resolve, 1000)) if (searchType === "onchain") { - router.push(`/search/?address=${encodeURIComponent(address)}`); + router.push(`/search/?address=${encodeURIComponent(address)}&network=${network}&provider=${provider}`) } else { - router.push(`/search-offchain/?address=${encodeURIComponent(address)}`); + router.push(`/search-offchain/?address=${encodeURIComponent(address)}`) } } catch (error) { - console.error("Search error:", error); + console.error("Search error:", error) + toast.error("An error occurred during search. Please try again.") } finally { - setIsLoading(false); + setIsLoading(false) } } - + const clearAddress = () => { setAddress("") + setAddressError(null) } + + const handleAddressChange = (e: React.ChangeEvent) => { + setAddress(e.target.value); + + // Clear error when user starts typing again + if (addressError) { + setAddressError(null); + } + }; + + const availableNetworks = getAvailableNetworks() return ( <> -
    -
    + +
    setAddress(e.target.value)} - className="pl-10 pr-10 py-2 w-full transition-all duration-200 focus:border-amber-500" + onChange={handleAddressChange} + className={`w-full pl-14 pr-12 py-3 text-white bg-gray-900/80 border rounded-xl focus:ring-2 placeholder-gray-400 transition-all duration-300 ease-out shadow-[0_0_15px_rgba(245,166,35,0.2)] hover:shadow-[0_0_25px_rgba(245,166,35,0.4)] ${ + addressError + ? "border-red-500 focus:ring-red-500/50 focus:border-red-500" + : searchType === "onchain" + ? "border-amber-500/20 focus:ring-amber-500/50 focus:border-amber-500" + : "border-blue-500/20 focus:ring-blue-500/50 focus:border-blue-500" + }`} /> {address.length > 0 && ( )}
    + + {addressError && ( +
    + + {addressError} +
    + )} + +
    + - - setNetwork(value as NetworkType)} > - - - + + + + + + {availableNetworks.map((network) => ( + + {network.label} + + ))} + + + + ) : ( +
    + + Neo4j Graph Database +
    + )} + + +
    + ) -} - +} \ No newline at end of file diff --git a/components/search-offchain/TransactionGraphOffChain.tsx b/components/search-offchain/TransactionGraphOffChain.tsx index 065e4b5..3e82333 100644 --- a/components/search-offchain/TransactionGraphOffChain.tsx +++ b/components/search-offchain/TransactionGraphOffChain.tsx @@ -1,13 +1,22 @@ "use client"; import { useSearchParams, useRouter } from "next/navigation"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import dynamic from "next/dynamic"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Loader2} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from "lucide-react"; +import { toast } from "sonner"; -// Dynamically import ForceGraph2D (without generic type arguments) -const ForceGraph2D = dynamic(() => import("react-force-graph-2d"), { ssr: false }); +// Dynamically import ForceGraph2D +const ForceGraph2D = dynamic(() => import("react-force-graph-2d"), { + ssr: false, + loading: () => ( +
    + +
    + ), +}); interface Transaction { id: string; @@ -17,12 +26,12 @@ interface Transaction { timestamp: string; } -// Define our node type with our custom properties. export interface GraphNode { id: string; label: string; color: string; type: string; + value?: number; // Adding value to size nodes properly x?: number; y?: number; vx?: number; @@ -31,24 +40,24 @@ export interface GraphNode { fy?: number; } +interface GraphLink { + source: string | GraphNode; + target: string | GraphNode; + value: number; + transaction?: Transaction; + color?: string; + curvature?: number; +} + interface GraphData { nodes: GraphNode[]; - links: { source: string; target: string; value: number }[]; + links: GraphLink[]; } const getRandomColor = () => `#${Math.floor(Math.random() * 16777215).toString(16)}`; function shortenAddress(address: string): string { - return `${address.slice(0, 3)}...${address.slice(-2)}`; -} - -// A mock function to get a name for an address (replace with your actual logic) -function getNameForAddress(address: string): string | null { - const mockNames: { [key: string]: string } = { - "0x1234567890123456789012345678901234567890": "Alice", - "0x0987654321098765432109876543210987654321": "Bob", - }; - return mockNames[address] || null; + return `${address.slice(0, 6)}...${address.slice(-4)}`; } export default function TransactionGraphOffChain() { @@ -58,46 +67,81 @@ export default function TransactionGraphOffChain() { const [graphData, setGraphData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - + const [isFullscreen, setIsFullscreen] = useState(false); + const graphRef = useRef(null); + const [hoverNode, setHoverNode] = useState(null); + useEffect(() => { if (address) { setLoading(true); setError(null); + fetch(`/api/transactions-offchain?address=${address}&offset=50`) .then((res) => res.json()) .then((data: unknown) => { if (!Array.isArray(data)) { - throw new Error((data as any).error || "Unexpected DB response"); + throw new Error((data as any).error || "Unexpected API response"); } + const transactions = data as Transaction[]; const nodes = new Map(); - const links: GraphData["links"] = []; + const links: GraphLink[] = []; + + // Track transaction counts for each address to size nodes appropriately + const txCounts = new Map(); + + // Initialize main address + txCounts.set(address, 0); + + // Add the main address node + nodes.set(address, { + id: address, + label: shortenAddress(address), + color: "#f5b056", // Amber color for the main node + type: "both", + value: 10, // Start with larger size for main node + }); transactions.forEach((tx) => { + // Count transactions for node sizing + txCounts.set(tx.from, (txCounts.get(tx.from) || 0) + 1); + txCounts.set(tx.to, (txCounts.get(tx.to) || 0) + 1); + if (!nodes.has(tx.from)) { - const name = getNameForAddress(tx.from); nodes.set(tx.from, { id: tx.from, - label: name || shortenAddress(tx.from), + label: shortenAddress(tx.from), color: getRandomColor(), type: tx.from === address ? "out" : "in", }); } if (!nodes.has(tx.to)) { - const name = getNameForAddress(tx.to); nodes.set(tx.to, { id: tx.to, - label: name || shortenAddress(tx.to), + label: shortenAddress(tx.to), color: getRandomColor(), type: tx.to === address ? "in" : "out", }); } + + // Use stronger colors with higher opacity for better visibility links.push({ source: tx.from, target: tx.to, - value: Number.parseFloat(tx.value), + value: Math.max(0.5, Math.min(3, Number.parseFloat(tx.value) || 1)), + transaction: tx, + color: tx.from === address ? "rgba(239, 68, 68, 0.8)" : "rgba(34, 197, 94, 0.8)", // Brighter colors + curvature: 0.2, // Increase curvature for better visualization }); }); + + // Update node values based on transaction counts + for (const [nodeId, count] of txCounts.entries()) { + const node = nodes.get(nodeId); + if (node) { + node.value = Math.max(3, Math.min(12, 3 + count)); // Scale between 3-12 based on transaction count + } + } setGraphData({ nodes: Array.from(nodes.values()), @@ -112,51 +156,66 @@ export default function TransactionGraphOffChain() { } }, [address]); - // Update onNodeClick to accept both the node and the MouseEvent. const handleNodeClick = useCallback( - (node: { [others: string]: any }, event: MouseEvent) => { - const n = node as GraphNode; - router.push(`/search/?address=${n.id}`); + (node: any) => { + const graphNode = node as GraphNode; + if (graphNode.id.startsWith("tx-")) return; // Skip transaction nodes + router.push(`/search-offchain/?address=${graphNode.id}`); }, [router] ); - - // Update nodes to reflect their transaction type ("both" if a node has both incoming and outgoing links) - useEffect(() => { - if (graphData) { - const updatedNodes: GraphNode[] = graphData.nodes.map((node) => { - const incoming = graphData.links.filter(link => link.target === node.id); - const outgoing = graphData.links.filter(link => link.source === node.id); - if (incoming.length > 0 && outgoing.length > 0) { - // Explicitly assert that the type is the literal "both" - return { ...node, type: "both" as "both" }; - } - return node; - }); - if (JSON.stringify(updatedNodes) !== JSON.stringify(graphData.nodes)) { - // Use the existing graphData rather than a functional update. - setGraphData({ - ...graphData, - nodes: updatedNodes, - }); + + const handleNodeHover = useCallback( + (node: { [others: string]: any; id?: string | number; x?: number; y?: number } | null) => { + if (node && 'label' in node && 'color' in node && 'type' in node) { + setHoverNode(node as GraphNode); + document.body.style.cursor = 'pointer'; + } else { + setHoverNode(null); + document.body.style.cursor = 'default'; } + }, + [] + ); + + const handleZoomIn = () => { + if (graphRef.current) { + const currentZoom = graphRef.current.zoom(); + graphRef.current.zoom(currentZoom * 1.2, 400); // 20% zoom in with 400ms animation } - }, [graphData]); + }; + + const handleZoomOut = () => { + if (graphRef.current) { + const currentZoom = graphRef.current.zoom(); + graphRef.current.zoom(currentZoom / 1.2, 400); // 20% zoom out with 400ms animation + } + }; + + const handleResetZoom = () => { + if (graphRef.current) { + graphRef.current.zoom(1, 800); // Reset to zoom level 1 with 800ms animation + graphRef.current.centerAt(0, 0, 800); // Center the graph + } + }; + + const toggleFullscreen = () => { + setIsFullscreen(!isFullscreen); + }; if (loading) { return ( - - + + +

    Loading transaction graph...

    ); } if (error) { return ( - - -

    Error: {error}

    -
    + +

    {error}

    ); } @@ -166,49 +225,143 @@ export default function TransactionGraphOffChain() { } return ( - - - Transaction Graph + + +
    + + Transaction Graph + {graphData.links.length > 0 ? ( + + {graphData.links.length} {graphData.links.length === 1 ? "Transaction" : "Transactions"} + + ) : ( + + No Transactions + + )} + +
    + + + + +
    +
    - + node.id) as any} - nodeColor={((node: GraphNode) => node.color) as any} - nodeCanvasObject={ - ((node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { - if (node.x == null || node.y == null) return; - const { label, type, x, y } = node; - const fontSize = 4; - ctx.font = `${fontSize}px Sans-Serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.beginPath(); - ctx.arc(x, y, type === "both" ? 4 : 3, 0, 2 * Math.PI, false); - ctx.fillStyle = - type === "in" - ? "rgba(0, 255, 0, 0.5)" - : type === "out" - ? "rgba(255, 0, 0, 0.5)" - : "rgba(255, 255, 0, 0.5)"; - ctx.fill(); - ctx.fillStyle = "white"; - ctx.fillText(label, x, y); - }) as any - } - nodeRelSize={6} - linkWidth={1} - linkColor={() => "rgb(255, 255, 255)"} + nodeRelSize={6} // Increase the relative node size linkDirectionalParticles={2} - linkDirectionalParticleWidth={3} + linkDirectionalParticleWidth={2} linkDirectionalParticleSpeed={0.005} - d3VelocityDecay={0.3} - d3AlphaDecay={0.01} + linkWidth={(link) => Math.sqrt((link as GraphLink).value) * 0.5} // Better scaling for link width + linkColor={(link) => (link as GraphLink).color || "rgba(255, 255, 255, 0.3)"} + linkCurvature={(link) => (link as GraphLink).curvature || 0.2} + d3AlphaDecay={0.02} // Slower cooling for better layout + d3VelocityDecay={0.1} // Less resistance for better separation + cooldownTime={3000} // Longer cooldown for better positioning + onNodeHover={handleNodeHover} onNodeClick={handleNodeClick} - width={580} - height={440} + nodeCanvasObject={(node, ctx, globalScale) => { + const { id, label, type, value, x, y } = node as GraphNode; + if (x == null || y == null) return; + + // Calculate node size based on value with minimum size + const nodeSize = (value || 5) * 0.8; + + // Draw node circle + ctx.beginPath(); + ctx.arc(x, y, nodeSize, 0, 2 * Math.PI, false); + + // Use consistent coloring based on node type + ctx.fillStyle = + type === "in" ? "rgba(34, 197, 94, 0.9)" : + type === "out" ? "rgba(239, 68, 68, 0.9)" : + "rgba(245, 176, 86, 0.9)"; + + // Add highlighting for hovered nodes + if (hoverNode && hoverNode.id === id) { + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2; + ctx.stroke(); + } + + ctx.fill(); + + // Only show labels if we're zoomed in enough or on hover + const labelVisible = globalScale > 1.2 || (hoverNode && hoverNode.id === id); + + if (labelVisible) { + // Draw a background for the text to improve readability + const fontSize = 8 / Math.sqrt(globalScale); + ctx.font = `${fontSize}px Arial`; + const textWidth = ctx.measureText(label).width; + const bckgDimensions = [textWidth + 8, fontSize + 4].map(n => n + 2); + + ctx.fillStyle = "rgba(0, 0, 0, 0)"; + ctx.fillRect( + x - bckgDimensions[0] / 2, + y + nodeSize + 2, + bckgDimensions[0], + bckgDimensions[1] + ); + + // Draw text + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = "white"; + ctx.fillText( + label, + x, + y + nodeSize + 2 + bckgDimensions[1] / 2 + ); + } + }} + width={isFullscreen ? window.innerWidth : 580} + height={isFullscreen ? window.innerHeight - 116 : 510} /> + +
    + {hoverNode && `Selected: ${hoverNode.label}`} +
    + +
    ); -} +} \ No newline at end of file diff --git a/components/search-offchain/TransactionTableOffChain.tsx b/components/search-offchain/TransactionTableOffChain.tsx index d559bd7..076a5b2 100644 --- a/components/search-offchain/TransactionTableOffChain.tsx +++ b/components/search-offchain/TransactionTableOffChain.tsx @@ -20,7 +20,7 @@ interface Transaction { export default function TransactionTableOffChain() { const searchParams = useSearchParams() - const address = searchParams.get("address") + const address = searchParams?.get("address") ?? null const [transactions, setTransactions] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) diff --git a/components/search/NFTGallery.tsx b/components/search/NFTGallery.tsx index 7ba1955..6a59866 100644 --- a/components/search/NFTGallery.tsx +++ b/components/search/NFTGallery.tsx @@ -86,7 +86,7 @@ const isPotentialSpam = (nft: NFT): boolean => { export default function NFTGallery() { const searchParams = useSearchParams() - const address = searchParams.get("address") + const address = searchParams?.get("address") ?? null const [nfts, setNFTs] = useState([]) const [totalCount, setTotalCount] = useState(0) const [pageKeys, setPageKeys] = useState<(string | null)[]>([null]) diff --git a/components/search/Portfolio.tsx b/components/search/Portfolio.tsx index 377c8b4..e2ab854 100644 --- a/components/search/Portfolio.tsx +++ b/components/search/Portfolio.tsx @@ -34,7 +34,7 @@ interface TokenBalance { export default function Portfolio() { const searchParams = useSearchParams() - const address = searchParams.get("address") + const address = searchParams?.get("address") ?? null const [portfolio, setPortfolio] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) diff --git a/components/search/TransactionGraph.tsx b/components/search/TransactionGraph.tsx index 9f7b49f..addda08 100644 --- a/components/search/TransactionGraph.tsx +++ b/components/search/TransactionGraph.tsx @@ -323,9 +323,9 @@ const TransactionDetails = ({ transaction, isOpen, onClose, network }: Transacti function TransactionGraph() { const searchParams = useSearchParams(); const router = useRouter(); - const address = searchParams.get("address"); - const network = searchParams.get("network") || "mainnet"; - const provider = searchParams.get("provider") || "etherscan"; + const address = searchParams?.get("address") ?? null; + const network = searchParams?.get("network") ?? "mainnet"; + const provider = searchParams?.get("provider") ?? "etherscan"; const [graphData, setGraphData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); diff --git a/components/search/TransactionTable.tsx b/components/search/TransactionTable.tsx index 155d51b..5f37ff6 100644 --- a/components/search/TransactionTable.tsx +++ b/components/search/TransactionTable.tsx @@ -38,9 +38,9 @@ const transactionCache = new Map([]) const [loading, setLoading] = useState(false) diff --git a/components/search/WalletInfo.tsx b/components/search/WalletInfo.tsx index 784b438..4ceea26 100644 --- a/components/search/WalletInfo.tsx +++ b/components/search/WalletInfo.tsx @@ -28,9 +28,9 @@ interface WalletData { export default function WalletInfo() { const searchParams = useSearchParams() - const address = searchParams.get("address") - const network = searchParams.get("network") || "mainnet" - const provider = searchParams.get("provider") || "etherscan" + const address = searchParams?.get("address") ?? null + const network = searchParams?.get("network") ?? "mainnet" + const provider = searchParams?.get("provider") ?? "etherscan" const [walletData, setWalletData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) diff --git a/components/ui/TransactionTable.tsx b/components/ui/TransactionTable.tsx index 181dee9..488f2fe 100644 --- a/components/ui/TransactionTable.tsx +++ b/components/ui/TransactionTable.tsx @@ -20,7 +20,7 @@ interface Transaction { export default function TransactionTable() { const searchParams = useSearchParams() - const address = searchParams.get("address") + const address = searchParams?.get("address") ?? null const [transactions, setTransactions] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx index df61a13..86bc2d4 100644 --- a/components/ui/checkbox.tsx +++ b/components/ui/checkbox.tsx @@ -6,7 +6,8 @@ import { Check } from "lucide-react" import { cn } from "@/lib/utils" -const Checkbox = React.forwardRef< +// Original Checkbox component +export const Checkbox = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( @@ -27,4 +28,49 @@ const Checkbox = React.forwardRef< )) Checkbox.displayName = CheckboxPrimitive.Root.displayName -export { Checkbox } +// Create a "safe" non-button checkbox that can be used inside buttons +// This uses a div instead of a button for the root element +interface SafeCheckboxProps extends Omit, 'onClick'> { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; +} + +export const SafeCheckbox = React.forwardRef( + ({ className, onCheckedChange, checked, ...props }, ref) => { + const [isChecked, setIsChecked] = React.useState(checked || false); + + React.useEffect(() => { + setIsChecked(!!checked); + }, [checked]); + + // Fix the type to explicitly use HTMLDivElement + const handleClick = React.useCallback(() => { + const newChecked = !isChecked; + setIsChecked(newChecked); + if (onCheckedChange) { + onCheckedChange(newChecked); + } + }, [isChecked, onCheckedChange]); + + return ( +
    + {isChecked && ( +
    + +
    + )} +
    + ); +}); +SafeCheckbox.displayName = "SafeCheckbox"; diff --git a/lib/api/alchemyNFTApi.ts b/lib/api/alchemyNFTApi.ts index a50a132..b4e8ed7 100644 --- a/lib/api/alchemyNFTApi.ts +++ b/lib/api/alchemyNFTApi.ts @@ -1,7 +1,136 @@ import { toast } from "sonner"; import axios from 'axios'; +import { + getNFTsByContract, + getContractMetadata, + getNFTMetadata, + getNFTsByWallet, + transformMoralisNFT +} from './moralisApi'; const ALCHEMY_API_KEY = process.env.NEXT_PUBLIC_ALCHEMY_API_KEY || 'demo'; +const BSCSCAN_API_KEY = process.env.BSCSCAN_API_KEY || '1QGN2GHNEPT6CQP854TVBH24C85714ETC5'; + +// Simple in-memory cache for BSCScan responses to avoid hitting rate limits +const responseCache = new Map(); +const CACHE_TTL = 60000; // 1 minute cache TTL + +// Queue system for BSCScan API calls to avoid rate limiting +const bscRequestQueue: (() => Promise)[] = []; +let isProcessingQueue = false; +let lastRequestTime = 0; +const REQUEST_DELAY = 250; // ms between requests (4 per second to stay under the 5/sec limit) + +// Process the BSCScan request queue +async function processBscRequestQueue() { + if (isProcessingQueue || bscRequestQueue.length === 0) return; + + isProcessingQueue = true; + + try { + while (bscRequestQueue.length > 0) { + const request = bscRequestQueue.shift(); + if (request) { + // Ensure minimum delay between requests + const now = Date.now(); + const elapsed = now - lastRequestTime; + if (elapsed < REQUEST_DELAY) { + await new Promise(resolve => setTimeout(resolve, REQUEST_DELAY - elapsed)); + } + + await request(); + lastRequestTime = Date.now(); + } + } + } finally { + isProcessingQueue = false; + + // If new requests were added while processing, start again + if (bscRequestQueue.length > 0) { + processBscRequestQueue(); + } + } +} + +// Ensure each BSCScan API call has valid parameters and improve error handling +async function cachedBscScanRequest(params: Record, chainId: string, retries = 2): Promise { + // Validate required parameters + if (!params.module || !params.action) { + console.error("Missing required BSCScan API parameters", params); + throw new Error("BSCScan API request missing required parameters: module and action must be specified"); + } + + const cacheKey = JSON.stringify(params) + chainId; + const cachedResponse = responseCache.get(cacheKey); + + // Return cached response if valid + if (cachedResponse && (Date.now() - cachedResponse.timestamp) < CACHE_TTL) { + return cachedResponse.data; + } + + // Add request to queue and return a promise + return new Promise((resolve, reject) => { + const executeRequest = async () => { + const baseUrl = chainId === '0x38' ? 'https://api.bscscan.com/api' : 'https://api-testnet.bscscan.com/api'; + + try { + // Add API key to params + const requestParams = { + ...params, + apikey: BSCSCAN_API_KEY + }; + + // Debug log the request (only in development) + if (process.env.NODE_ENV === 'development') { + console.log(`BSCScan API request: ${baseUrl}`, requestParams); + } + + const response = await axios.get(baseUrl, { params: requestParams }); + const data = response.data; + + // Check for rate limit errors + if (data.status === '0' && data.message === 'NOTOK') { + console.warn("BSCScan API error:", data.result); + + if (data.result.includes('rate limit') && retries > 0) { + console.warn("BSCScan rate limit hit, retrying after delay..."); + // Wait a bit longer before retrying + await new Promise(resolve => setTimeout(resolve, 1500)); // Increased delay + + // Retry with one fewer retry attempt + const retryResult = await cachedBscScanRequest(params, chainId, retries - 1); + resolve(retryResult); + return; + } else if (data.result.includes('Missing Or invalid')) { + // Log detailed information about the invalid request + console.error("Invalid BSCScan API request:", { + url: baseUrl, + params: requestParams, + error: data.result + }); + reject(new Error(`BSCScan API error: ${data.result}`)); + return; + } + } + + // Cache the successful response + responseCache.set(cacheKey, { + data, + timestamp: Date.now() + }); + + resolve(data); + } catch (error) { + console.error("BSCScan API request failed:", error); + reject(error); + } + }; + + // Add to queue and start processing + bscRequestQueue.push(executeRequest); + processBscRequestQueue(); + }); +} const CHAIN_ID_TO_NETWORK: Record = { '0x1': 'eth-mainnet', @@ -11,10 +140,14 @@ const CHAIN_ID_TO_NETWORK: Record = { '0x13881': 'polygon-mumbai', '0xa': 'optimism-mainnet', '0xa4b1': 'arbitrum-mainnet', - '0x38': 'bsc-mainnet', - '0x61': 'bsc-testnet', + // BNB Chain networks are handled separately with BSCScan }; +// Check if a chain is BNB-based +function isBNBChain(chainId: string): boolean { + return chainId === '0x38' || chainId === '0x61'; +} + interface AlchemyNFTResponse { ownedNfts: any[]; totalCount: number; @@ -300,11 +433,11 @@ const mockCollections = [ // Real BNB Chain collections const mockBNBCollections = [ { - id: '0xdcbcf766dcd33a7a8abe6b01a8b0e44a006c4ac1', + id: '0x0a8901b0E25DEb55A87524f0cC164E9644020EBA', name: 'Pancake Squad', description: 'PancakeSwap\'s NFT collection of 10,000 unique bunnies designed to reward loyal community members and bring utility to the CAKE token.', - imageUrl: 'https://assets.pancakeswap.finance/pancakeSquad/header.png', - bannerImageUrl: 'https://assets.pancakeswap.finance/pancakeSquad/pancakeSquadBanner.png', + imageUrl: 'https://i.seadn.io/s/raw/files/8b1d3939c420d39c8914f68b506c50db.png?auto=format&dpr=1&w=256', + bannerImageUrl: 'https://i.seadn.io/s/primary-drops/0xc291cc12018a6fcf423699bce985ded86bac47cb/33406336:about:media:6f541d5a-5309-41ad-8f73-74f092ed1314.png?auto=format&dpr=1&w=1200', floorPrice: '2.5', totalSupply: '10000', chain: '0x38', @@ -376,18 +509,45 @@ const cryptoPathCollection = { featured: true }; -const API_ENDPOINTS = { - '0x1': 'https://eth-mainnet.g.alchemy.com/v2/your-api-key', - '0xaa36a7': 'https://eth-sepolia.g.alchemy.com/v2/your-api-key', - '0x38': 'https://bsc-mainnet.g.alchemy.com/v2/your-api-key', - '0x61': 'https://bsc-testnet.g.alchemy.com/v2/your-api-key' -}; - export async function fetchUserNFTs(address: string, chainId: string, pageKey?: string): Promise { if (!address) { throw new Error("Address is required to fetch NFTs"); } + // For BNB Chain, use Moralis API instead of BSCScan + if (isBNBChain(chainId)) { + try { + const response = await getNFTsByWallet(address, chainId); + + // Convert to Alchemy-like format for compatibility + const ownedNfts = response.result.map((nft: any) => { + return { + contract: { + address: nft.token_address, + name: nft.name || 'Unknown', + symbol: nft.symbol || '', + }, + id: { + tokenId: nft.token_id, + }, + balance: nft.amount || '1', + media: [{ gateway: nft.media?.media_url || '' }], + tokenUri: { gateway: nft.token_uri || '', raw: nft.token_uri || '' } + }; + }); + + return { + ownedNfts, + totalCount: response.total || ownedNfts.length + }; + } catch (error) { + console.error(`Error fetching NFTs for ${address} from Moralis:`, error); + toast.error("Failed to load NFTs"); + return { ownedNfts: [], totalCount: 0 }; + } + } + + // For non-BNB chains, continue using Alchemy const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; try { @@ -421,6 +581,104 @@ export async function fetchCollectionInfo(contractAddress: string, chainId: stri throw new Error("Contract address is required"); } + // For BNB Chain networks, use Moralis API (replacing BSCScan) + if (isBNBChain(chainId)) { + try { + // First try Moralis for metadata + try { + const metadata = await getContractMetadata(contractAddress, chainId); + + // Try to use the mock data if available for better UX (mockup collections) + if (chainId === '0x38') { + const mockCollection = mockBNBCollections.find(c => + c.id.toLowerCase() === contractAddress.toLowerCase() + ); + + if (mockCollection) { + return { + name: mockCollection.name, + symbol: '', + totalSupply: mockCollection.totalSupply, + description: mockCollection.description, + imageUrl: mockCollection.imageUrl + }; + } + } else if (chainId === '0x61' && contractAddress.toLowerCase() === '0x2fF12fE4B3C4DEa244c4BdF682d572A90Df3B551'.toLowerCase()) { + // Use CryptoPath collection info for testnet + return { + name: cryptoPathCollection.name, + symbol: 'CP', + totalSupply: cryptoPathCollection.totalSupply, + description: cryptoPathCollection.description, + imageUrl: cryptoPathCollection.imageUrl + }; + } + + // Parse metadata from Moralis + return { + name: metadata.name || 'Unknown Collection', + symbol: metadata.symbol || '', + totalSupply: metadata.synced_at ? '?' : '0', // Moralis doesn't provide totalSupply directly + description: metadata.description || '', + imageUrl: metadata.thumbnail || '', + }; + } catch (moralisError) { + console.warn("Error fetching collection info from Moralis:", moralisError); + + // Fallback to BSCScan as before with simplified approach + try { + // Try to use the mock data if available for better UX + if (chainId === '0x38') { + const mockCollection = mockBNBCollections.find(c => + c.id.toLowerCase() === contractAddress.toLowerCase() + ); + + if (mockCollection) { + return { + name: mockCollection.name, + symbol: '', + totalSupply: mockCollection.totalSupply, + description: mockCollection.description, + imageUrl: mockCollection.imageUrl + }; + } + } else if (chainId === '0x61' && contractAddress.toLowerCase() === '0x2fF12fE4B3C4DEa244c4BdF682d572A90Df3B551'.toLowerCase()) { + // Use CryptoPath collection info for testnet + return { + name: cryptoPathCollection.name, + symbol: 'CP', + totalSupply: cryptoPathCollection.totalSupply, + description: cryptoPathCollection.description, + imageUrl: cryptoPathCollection.imageUrl + }; + } + } catch (bscError) { + console.error("Error with BSCScan fallback:", bscError); + } + + // Return default values if all else fails + return { + name: 'Unknown Collection', + symbol: '', + totalSupply: '0', + description: '', + imageUrl: '', + }; + } + } catch (error) { + console.error(`Error fetching collection info for ${contractAddress}:`, error); + toast.error("Failed to load collection info"); + return { + name: 'Unknown Collection', + symbol: '', + totalSupply: '0', + description: '', + imageUrl: '', + }; + } + } + + // For non-BNB chains, continue using Alchemy const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; try { @@ -486,7 +744,6 @@ export interface CollectionNFTsResponse { pageKey?: string; } - export async function fetchCollectionNFTs( contractAddress: string, chainId: string, @@ -501,7 +758,106 @@ export async function fetchCollectionNFTs( throw new Error("Contract address is required"); } - // For other collections, continue with the existing implementation + // For BNB Chain networks, use Moralis API instead of BSCScan + if (isBNBChain(chainId)) { + try { + // Calculate cursor based on page + const cursor = undefined; + if (page > 1) { + // Use a deterministic cursor approach - we'll just skip items + const skip = (page - 1) * pageSize; + // Note: This is simplified - in a real app you'd store and pass actual cursors + } + + // Fetch NFTs from Moralis + const response = await getNFTsByContract(contractAddress, chainId, cursor, pageSize); + + if (!response.result || response.result.length === 0) { + // If we don't have results, try to use mock data for known collections + if (chainId === '0x61' && contractAddress.toLowerCase() === '0x2fF12fE4B3C4DEa244c4BdF682d572A90Df3B551'.toLowerCase()) { + // Generate mock data for our demo CryptoPath collection + const mockNfts = generateMockNFTs(contractAddress, chainId, page, pageSize); + return { + nfts: mockNfts, + totalCount: 1000 // Mock total count + }; + } + + return { nfts: [], totalCount: 0 }; + } + + // Transform NFTs to our format + let nfts = response.result.map((nft: any) => transformMoralisNFT(nft, chainId)); + + // Apply search filter if needed + if (searchQuery) { + const query = searchQuery.toLowerCase(); + nfts = nfts.filter((nft: CollectionNFT) => + nft.name.toLowerCase().includes(query) || + nft.tokenId.toLowerCase().includes(query) + ); + } + + // Apply attribute filters if needed + if (Object.keys(attributes).length > 0) { + nfts = nfts.filter((nft: CollectionNFT) => { + for (const [traitType, values] of Object.entries(attributes)) { + if (traitType === 'Network') continue; // Skip the Network filter we added + + const nftAttribute = nft.attributes?.find(attr => attr.trait_type === traitType); + if (!nftAttribute || !values.includes(nftAttribute.value)) { + return false; + } + } + return true; + }); + } + + // Apply sorting + nfts.sort((a: CollectionNFT, b: CollectionNFT) => { + if (sortBy === 'tokenId') { + const numA = parseInt(a.tokenId, 10); + const numB = parseInt(b.tokenId, 10); + + if (!isNaN(numA) && !isNaN(numB)) { + return sortDirection === 'asc' ? numA - numB : numB - numA; + } + + return sortDirection === 'asc' + ? a.tokenId.localeCompare(b.tokenId) + : b.tokenId.localeCompare(a.tokenId); + } else if (sortBy === 'name') { + return sortDirection === 'asc' + ? a.name.localeCompare(b.name) + : b.name.localeCompare(a.name); + } + return 0; + }); + + return { + nfts, + totalCount: response.total || nfts.length + }; + } catch (error) { + console.error(`Error fetching NFTs from Moralis for collection ${contractAddress}:`, error); + + // Try to use mock data for known collections as a fallback + if (chainId === '0x61' && contractAddress.toLowerCase() === '0x2fF12fE4B3C4DEa244c4BdF682d572A90Df3B551'.toLowerCase()) { + console.log("Generating mock NFTs for CryptoPath collection"); + // Generate mock data for our demo CryptoPath collection + const mockNfts = generateMockNFTs(contractAddress, chainId, page, pageSize); + return { + nfts: mockNfts, + totalCount: 1000 // Mock total count + }; + } + + toast.error("Failed to load collection NFTs"); + return { nfts: [], totalCount: 0 }; + } + } + + // For non-BNB chains, continue using Alchemy but with CORS handling const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; try { @@ -512,9 +868,31 @@ export async function fetchCollectionNFTs( url.searchParams.append('startToken', ((page - 1) * pageSize).toString()); url.searchParams.append('limit', pageSize.toString()); - const response = await fetch(url.toString()); + // Use serverless API routes for CORS issues + if (process.env.NEXT_PUBLIC_USE_SERVERLESS === 'true') { + // Updated API path to match new location + const response = await fetch(`/api/nfts/collection?url=${encodeURIComponent(url.toString())}`); + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}`); + } + return await response.json(); + } + + // Use cross-origin directly for development/testing + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Accept': 'application/json', + }, + // Add exponential backoff retry logic + ...createFetchRetryConfig() + }); if (!response.ok) { + // Handle specific error codes + if (response.status === 429) { + throw new Error('Rate limit exceeded. Please try again later.'); + } throw new Error(`API request failed with status ${response.status}`); } @@ -528,6 +906,7 @@ export async function fetchCollectionNFTs( description: nft.description || '', imageUrl: nft.media?.[0]?.gateway || '', attributes: nft.metadata?.attributes || [], + chain: chainId // Add missing chain property })); // Apply filters @@ -576,11 +955,69 @@ export async function fetchCollectionNFTs( }; } catch (error) { console.error(`Error fetching NFTs for collection ${contractAddress}:`, error); - toast.error("Failed to load collection NFTs"); - return { nfts: [], totalCount: 0 }; + throw error; // Rethrow for fallback handling } } +// Helper function to create fetch retry configuration +function createFetchRetryConfig(maxRetries = 3, initialDelay = 1000) { + return { + retry: async (attempt: number, error: Error, response: Response) => { + if (attempt >= maxRetries) return false; + + // Check if we should retry based on the error or response + const shouldRetry = !response || response.status === 429 || response.status >= 500; + + if (shouldRetry) { + // Exponential backoff with jitter + const delay = initialDelay * Math.pow(2, attempt) + Math.random() * 1000; + console.log(`Retrying fetch (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); + return true; + } + + return false; + } + }; +} + +// Helper function to generate mock NFT data for testing +function generateMockNFT(tokenId: string, contractAddress: string, chainId: string): CollectionNFT { + // Generate predictable but random-looking attributes based on tokenId + const tokenNum = parseInt(tokenId as string, 10); + const seed = tokenNum % 100; + + // Background options + const backgrounds = ['Blue', 'Red', 'Green', 'Purple', 'Gold', 'Black', 'White']; + const backgroundIndex = seed % backgrounds.length; + + // Species options + const species = ['Human', 'Ape', 'Robot', 'Alien', 'Zombie', 'Demon', 'Angel']; + const speciesIndex = (seed * 3) % species.length; + + // Rarity options + const rarities = ['Common', 'Uncommon', 'Rare', 'Epic', 'Legendary']; + const rarityIndex = Math.floor(seed / 20); // 0-4 + + return { + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId: String(tokenId), + name: `CryptoPath #${tokenId}`, + description: `A unique NFT from the CryptoPath Genesis Collection with ${rarities[rarityIndex]} rarity.`, + imageUrl: `/Img/nft/sample-${(seed % 5) + 1}.jpg`, // Using sample images 1-5 + attributes: [ + { trait_type: 'Background', value: backgrounds[backgroundIndex] }, + { trait_type: 'Species', value: species[speciesIndex] }, + { trait_type: 'Rarity', value: rarities[rarityIndex] }, + // Network attribute for filtering + { trait_type: 'Network', value: chainId === '0x1' ? 'Ethereum' : + chainId === '0xaa36a7' ? 'Sepolia' : + chainId === '0x38' ? 'BNB Chain' : 'BNB Testnet' } + ], + chain: chainId // Add missing chain property + }; +} + // Mocked API service for NFT data // In a real application, this would connect to Alchemy or another provider export async function fetchPopularCollections(chainId: string): Promise { @@ -604,7 +1041,7 @@ export async function fetchPopularCollections(chainId: string): Promise { // For Ethereum and Sepolia return mockCollections.map(collection => ({ ...collection, - chain: chainId + chain: chainId })); } catch (error) { console.error('Error fetching collections:', error); @@ -680,3 +1117,48 @@ export async function fetchPriceHistory(tokenId?: string): Promise { return data; } + +// Generate mock NFTs for testing - particularly useful for our testnet collection +function generateMockNFTs(contractAddress: string, chainId: string, page: number, pageSize: number): CollectionNFT[] { + const startIndex = (page - 1) * pageSize + 1; + const nfts: CollectionNFT[] = []; + + for (let i = 0; i < pageSize; i++) { + const tokenId = String(startIndex + i); + + // Generate deterministic but varied attributes based on token ID + const tokenNum = parseInt(tokenId, 10); + const seed = tokenNum % 100; + + // Background options + const backgrounds = ['Blue', 'Red', 'Green', 'Purple', 'Gold', 'Black', 'White']; + const backgroundIndex = seed % backgrounds.length; + + // Species options + const species = ['Human', 'Ape', 'Robot', 'Alien', 'Zombie', 'Demon', 'Angel']; + const speciesIndex = (seed * 3) % species.length; + + // Rarity options + const rarities = ['Common', 'Uncommon', 'Rare', 'Epic', 'Legendary']; + const rarityIndex = Math.floor(seed / 20); // 0-4 + + nfts.push({ + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId: tokenId, + name: `CryptoPath #${tokenId}`, + description: `A unique NFT from the CryptoPath Genesis Collection with ${rarities[rarityIndex]} rarity.`, + imageUrl: `/Img/nft/sample-${(seed % 5) + 1}.jpg`, // Using sample images 1-5 + attributes: [ + { trait_type: 'Background', value: backgrounds[backgroundIndex] }, + { trait_type: 'Species', value: species[speciesIndex] }, + { trait_type: 'Rarity', value: rarities[rarityIndex] }, + { trait_type: 'Network', value: chainId === '0x1' ? 'Ethereum' : + chainId === '0xaa36a7' ? 'Sepolia' : + chainId === '0x38' ? 'BNB Chain' : 'BNB Testnet' } + ], + chain: chainId + }); + } + + return nfts; +} diff --git a/lib/api/coinApi.ts b/lib/api/coinApi.ts index 25b94ab..15f60c6 100644 --- a/lib/api/coinApi.ts +++ b/lib/api/coinApi.ts @@ -8,12 +8,16 @@ const CACHE_EXPIRY = 5 * 60 * 1000; // 5 phút const FETCH_TIMEOUT = 15000; // 15 giây timeout const MAX_RETRIES = 3; const MEMORY_CACHE = new Map(); -const limit = pLimit(5); // Giới hạn 5 request đồng thời +const limit = pLimit(2); // Giới hạn 2 request đồng thời + +// Proxy URL +const PROXY_URL = "/api/coingecko-proxy"; // Helper functions const getFromMemoryCache = (key: string): T | null => { const cached = MEMORY_CACHE.get(key); if (cached && Date.now() - cached.timestamp < CACHE_EXPIRY) { + console.log(`Retrieved from memory cache with key: ${key}`); return cached.data; } MEMORY_CACHE.delete(key); @@ -22,25 +26,31 @@ const getFromMemoryCache = (key: string): T | null => { const setToMemoryCache = (key: string, data: any) => { MEMORY_CACHE.set(key, { data, timestamp: Date.now() }); + console.log(`Saved to memory cache with key: ${key}`); }; const getLocalFallback = (key: string): T | null => { const item = localStorage.getItem(key); if (item) { const { data, timestamp } = JSON.parse(item); - if (Date.now() - timestamp < CACHE_EXPIRY * 2) return data; // Cache lâu hơn offline + if (Date.now() - timestamp < CACHE_EXPIRY * 2) { + console.log(`Retrieved from local fallback with key: ${key}`); + return data; + } } return null; }; const setLocalFallback = (key: string, data: any) => { localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() })); + console.log(`Saved to local fallback with key: ${key}`); }; // Fetch với timeout, retry và rate limiting -const fetchWithRetry = async (url: string, options: RequestInit = {}): Promise => { +const fetchWithRetry = async (endpoint: string, options: RequestInit = {}): Promise => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + const url = `${PROXY_URL}?endpoint=${encodeURIComponent(endpoint)}`; for (let i = 0; i < MAX_RETRIES; i++) { try { @@ -55,12 +65,35 @@ const fetchWithRetry = async (url: string, options: RequestInit = {}): Promise setTimeout(resolve, 1000 * Math.pow(2, i))); // Exponential backoff + + if (!response.ok) { + if (response.status === 429) { + const result = await response.json(); + const retryAfter = result.retryAfter || 60; + console.log(`Rate limit hit. Waiting ${retryAfter}s before retry. Attempt ${i + 1}/${MAX_RETRIES}`); + await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)); + continue; + } + throw new Error(`API request failed with status ${response.status}`); + } + + const result = await response.json(); + if (result.error) throw new Error(result.error); + console.log(`Successfully fetched data for endpoint: ${endpoint}`); + return result.data || result; + } catch (error: unknown) { // Xử lý error là unknown + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Request timed out"); + } + if (i === MAX_RETRIES - 1) { + console.error(`Max retries reached for ${endpoint}:`, error); + throw error instanceof Error ? error : new Error("Unknown error occurred"); + } + const delay = 1000 * Math.pow(2, i) + Math.random() * 1000; + console.log(`Retrying after ${delay}ms due to error: ${error instanceof Error ? error.message : String(error)}`); + await new Promise(resolve => setTimeout(resolve, delay)); } finally { clearTimeout(timeoutId); } @@ -70,15 +103,10 @@ const fetchWithRetry = async (url: string, options: RequestInit = {}): Promise => { const cacheKey = `coins_${page}_${perPage}`; - // Check memory cache const memoryCached = getFromMemoryCache(cacheKey); - if (memoryCached) { - console.log('Returning memory cached coin data'); - return memoryCached; - } + if (memoryCached) return memoryCached; try { - // Check Supabase cache const { data: cachedData, error } = await supabase .from('cached_coins') .select('data, last_updated') @@ -88,19 +116,17 @@ export const getCoins = async (page = 1, perPage = 20): Promise => { if (!error && cachedData?.last_updated) { const timeSinceUpdate = Date.now() - new Date(cachedData.last_updated).getTime(); if (timeSinceUpdate < CACHE_EXPIRY) { - console.log('Returning Supabase cached coin data'); + console.log(`Returning Supabase cached coin data for key: ${cacheKey}`); setToMemoryCache(cacheKey, cachedData.data); - setLocalFallback(cacheKey, cachedData.data); // Lưu fallback + setLocalFallback(cacheKey, cachedData.data); return cachedData.data as Coin[]; } } - // Fetch fresh data - console.log(`Fetching fresh coin data for page ${page}`); - const url = `https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=${perPage}&page=${page}&sparkline=true&price_change_percentage=1h,24h,7d&locale=en`; - const data = await fetchWithRetry(url); + console.log(`Fetching fresh coin data for page ${page}, perPage ${perPage}`); + const endpoint = `coins/markets?vs_currency=usd&order=market_cap_desc&per_page=${perPage}&page=${page}&sparkline=true&price_change_percentage=1h,24h,7d&locale=en`; + const data = await fetchWithRetry(endpoint); - // Update caches setToMemoryCache(cacheKey, data); setLocalFallback(cacheKey, data); await supabase.from('cached_coins').upsert({ @@ -108,18 +134,19 @@ export const getCoins = async (page = 1, perPage = 20): Promise => { data, last_updated: new Date().toISOString(), }); + console.log(`Saved to Supabase with key: ${cacheKey}`); return data; - } catch (error) { + } catch (error: unknown) { // Xử lý error là unknown console.error("Error fetching coins:", error); - toast.error("Failed to load cryptocurrency data"); - - // Return local fallback nếu có - const fallback = getLocalFallback(cacheKey); - if (fallback) { - console.log('Returning local fallback coin data'); - return fallback; + if (error instanceof Error && error.message.includes('429')) { + toast.error("Too many requests to CoinGecko. Please wait and try again later."); + } else { + toast.error("Failed to load cryptocurrency data. Using cached data if available."); } + + const fallback = getLocalFallback(cacheKey); + if (fallback) return fallback; return []; } }; @@ -129,55 +156,50 @@ export const getCoinDetail = async (id: string): Promise => { const cacheKey = `coin_detail_${id}`; - // Check memory cache const memoryCached = getFromMemoryCache(cacheKey); - if (memoryCached) { - console.log('Returning memory cached coin detail'); - return memoryCached; - } + if (memoryCached) return memoryCached; try { - // Check Supabase cache const { data: cachedData, error } = await supabase .from('cached_coin_details') .select('data, last_updated') - .eq('id', id) + .eq('id', cacheKey) .single(); if (!error && cachedData?.last_updated) { const timeSinceUpdate = Date.now() - new Date(cachedData.last_updated).getTime(); if (timeSinceUpdate < CACHE_EXPIRY) { - console.log('Returning Supabase cached coin detail'); + console.log(`Returning Supabase cached coin detail for key: ${cacheKey}`); setToMemoryCache(cacheKey, cachedData.data); setLocalFallback(cacheKey, cachedData.data); return cachedData.data as CoinDetail; } } - // Fetch fresh data console.log(`Fetching fresh data for coin: ${id}`); - const url = `https://api.coingecko.com/api/v3/coins/${id}?localization=false&tickers=false&market_data=true&community_data=false&developer_data=false&sparkline=true`; - const data = await fetchWithRetry(url, { cache: "no-store" }); + const endpoint = `coins/${id}?localization=false&tickers=false&market_data=true&community_data=false&developer_data=false&sparkline=true`; + const data = await fetchWithRetry(endpoint); - // Update caches setToMemoryCache(cacheKey, data); setLocalFallback(cacheKey, data); await supabase.from('cached_coin_details').upsert({ - id, + id: cacheKey, data, last_updated: new Date().toISOString(), }); + console.log(`Saved to Supabase with key: ${cacheKey}`); return data; - } catch (error) { + } catch (error: unknown) { // Xử lý error là unknown console.error(`Error fetching coin detail for ${id}:`, error); + if (error instanceof Error && error.message.includes('429')) { + toast.error("Too many requests to CoinGecko. Please wait and try again later."); + } else { + toast.error(`Failed to load details for ${id}. Using cached data if available.`); + } - // Return local fallback nếu có const fallback = getLocalFallback(cacheKey); - if (fallback) { - console.log('Returning local fallback coin detail'); - return fallback; - } + if (fallback) return fallback; throw error instanceof Error ? error : new Error("An unknown error occurred"); } }; @@ -185,31 +207,27 @@ export const getCoinDetail = async (id: string): Promise => { export const getCoinHistory = async (id: string, days = 7): Promise => { const cacheKey = `coin_history_${id}_${days}`; - // Check memory cache const memoryCached = getFromMemoryCache(cacheKey); - if (memoryCached) { - console.log('Returning memory cached coin history'); - return memoryCached; - } + if (memoryCached) return memoryCached; try { - // Fetch fresh data - const url = `https://api.coingecko.com/api/v3/coins/${id}/market_chart?vs_currency=usd&days=${days}`; - const data = await fetchWithRetry(url); + const endpoint = `coins/${id}/market_chart?vs_currency=usd&days=${days}`; + const data = await fetchWithRetry(endpoint); setToMemoryCache(cacheKey, data); setLocalFallback(cacheKey, data); + return data; - } catch (error) { + } catch (error: unknown) { // Xử lý error là unknown console.error(`Error fetching history for ${id}:`, error); - toast.error("Failed to load price history"); - - // Return local fallback hoặc dummy data - const fallback = getLocalFallback(cacheKey); - if (fallback) { - console.log('Returning local fallback coin history'); - return fallback; + if (error instanceof Error && error.message.includes('429')) { + toast.error("Too many requests to CoinGecko. Please wait and try again later."); + } else { + toast.error("Failed to load price history"); } + + const fallback = getLocalFallback(cacheKey); + if (fallback) return fallback; return { prices: Array.from({ length: 168 }, (_, i) => [Date.now() - (168 - i) * 3600000, 50000 + Math.random() * 10000]), market_caps: Array.from({ length: 168 }, (_, i) => [Date.now() - (168 - i) * 3600000, 1000000000000 + Math.random() * 1000000000]), @@ -218,13 +236,14 @@ export const getCoinHistory = async (id: string, days = 7): Promise } }; -// Hàm xóa cache nếu cần export const clearCache = (key?: string) => { if (key) { MEMORY_CACHE.delete(key); localStorage.removeItem(key); + console.log(`Cleared cache for key: ${key}`); } else { MEMORY_CACHE.clear(); localStorage.clear(); + console.log("Cleared all caches"); } }; \ No newline at end of file diff --git a/lib/api/moralisApi.ts b/lib/api/moralisApi.ts index 4e7ac9d..6c06825 100644 --- a/lib/api/moralisApi.ts +++ b/lib/api/moralisApi.ts @@ -1,6 +1,278 @@ import axios from 'axios'; +import { toast } from 'sonner'; + +const MORALIS_API_KEY = process.env.NEXT_PUBLIC_MORALIS_API_KEY || process.env.MORALIS_API_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjY4ODEyMzE5LWNiMDAtNDA3MC1iOTEyLWIzNTllYjI4ZjQyOCIsIm9yZ0lkIjoiNDM3Nzk0IiwidXNlcklkIjoiNDUwMzgyIiwidHlwZUlkIjoiYTU5Mzk2NGYtZWUxNi00NGY3LWIxMDUtZWNhMzAwMjUwMDg4IiwidHlwZSI6IlBST0pFQ1QiLCJpYXQiOjE3NDI3ODk3MzEsImV4cCI6NDg5ODU0OTczMX0.4XB5n8uVFQkMwMO2Ck4FbNQw8daQp1uDdMvXmYFr9WA'; + +// Simple in-memory cache for API responses +const responseCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes cache TTL + +// Rate limiting +const REQUEST_DELAY = 500; // ms between requests +let lastRequestTime = 0; + +// Chain mapping from chain ID to Moralis chain name +const CHAIN_MAPPING: Record = { + '0x1': 'eth', + '0xaa36a7': 'sepolia', + '0x38': 'bsc', + '0x61': 'bsc testnet' +}; + +/** + * Check if API key is valid for use + */ +export function isValidApiKey() { + return !!MORALIS_API_KEY && MORALIS_API_KEY.length > 20; +} + +/** + * Makes a rate-limited request to Moralis API + */ +async function moralisRequest( + endpoint: string, + params: Record = {}, + chainId: string +): Promise { + // Validate API key + if (!isValidApiKey()) { + throw new Error("Moralis API key not available or invalid"); + } + + // Validate chain + const chain = CHAIN_MAPPING[chainId]; + if (!chain) { + throw new Error(`Unsupported chain ID: ${chainId}`); + } + + // Create cache key + const cacheKey = `${endpoint}-${chain}-${JSON.stringify(params)}`; + + // Check cache first + const cachedResponse = responseCache.get(cacheKey); + if (cachedResponse && (Date.now() - cachedResponse.timestamp < CACHE_TTL)) { + return cachedResponse.data; + } + + // Ensure rate limiting + const now = Date.now(); + const elapsed = now - lastRequestTime; + if (elapsed < REQUEST_DELAY) { + await new Promise(resolve => setTimeout(resolve, REQUEST_DELAY - elapsed)); + } + + try { + // Add API key and chain to parameters + const requestParams = { + ...params, + chain: chain + }; + + // Make API request + const response = await axios.get(`https://deep-index.moralis.io/api/v2/${endpoint}`, { + params: requestParams, + headers: { + 'Accept': 'application/json', + 'X-API-Key': MORALIS_API_KEY + } + }); + + // Update last request time + lastRequestTime = Date.now(); + + // Cache the result + responseCache.set(cacheKey, { + data: response.data, + timestamp: Date.now() + }); + + return response.data; + } catch (error: any) { + console.error(`Moralis API error (${endpoint}):`, error.response?.data || error.message); + throw error; + } +} + +/** + * Get NFTs by contract address + */ +export async function getNFTsByContract( + contractAddress: string, + chainId: string, + cursor?: string, + limit: number = 20 +): Promise { + try { + const response = await moralisRequest( + `nft/${contractAddress}`, + { + limit, + cursor, + normalizeMetadata: true, + media_items: true + }, + chainId + ); + return response; + } catch (error) { + console.error('Error fetching NFTs by contract:', error); + throw error; + } +} + +/** + * Get NFT metadata for a specific token + */ +export async function getNFTMetadata( + contractAddress: string, + tokenId: string, + chainId: string +): Promise { + try { + const response = await moralisRequest( + `nft/${contractAddress}/${tokenId}`, + { normalizeMetadata: true, media_items: true }, + chainId + ); + return response; + } catch (error) { + console.error('Error fetching NFT metadata:', error); + throw error; + } +} + +/** + * Get contract metadata + */ +export async function getContractMetadata( + contractAddress: string, + chainId: string +): Promise { + try { + const response = await moralisRequest( + `nft/${contractAddress}/metadata`, + {}, + chainId + ); + return response; + } catch (error) { + console.error('Error fetching contract metadata:', error); + throw error; + } +} + +/** + * Get NFTs owned by a wallet address + */ +export async function getNFTsByWallet( + walletAddress: string, + chainId: string, + cursor?: string, + limit: number = 20 +): Promise { + try { + const response = await moralisRequest( + `${walletAddress}/nft`, + { + limit, + cursor, + normalizeMetadata: true, + media_items: true + }, + chainId + ); + return response; + } catch (error) { + console.error('Error fetching NFTs by wallet:', error); + throw error; + } +} + +/** + * Transform Moralis NFT data to match our CollectionNFT format + */ +export function transformMoralisNFT(nft: any, chainId: string): any { + // Extract image URL from metadata + let imageUrl = ''; + + // Handle different media formats - Moralis has inconsistent response structures + if (nft.media) { + if (Array.isArray(nft.media)) { + // If media is an array, find the first image item + const mediaItem = nft.media.find((m: any) => + m.media_collection && ['image', 'image_large', 'image_thumbnail'].includes(m.media_collection) + ); + if (mediaItem && mediaItem.media_url) { + imageUrl = mediaItem.media_url; + } + } else if (typeof nft.media === 'object' && nft.media !== null) { + // If media is an object (BNB Chain specific format) + if (nft.media.original_media_url) { + imageUrl = nft.media.original_media_url; + } else if (nft.media.media_url) { + imageUrl = nft.media.media_url; + } + } + } + + // Fallback to normalized metadata image + if (!imageUrl && nft.normalized_metadata && nft.normalized_metadata.image) { + imageUrl = nft.normalized_metadata.image; + + // Handle IPFS URLs + if (imageUrl.startsWith('ipfs://')) { + imageUrl = `https://ipfs.io/ipfs/${imageUrl.slice(7)}`; + } + } + + // Additional fallback for the raw metadata + if (!imageUrl && nft.metadata) { + try { + // Sometimes metadata is a string that needs parsing + const parsedMetadata = typeof nft.metadata === 'string' + ? JSON.parse(nft.metadata) + : nft.metadata; + + if (parsedMetadata.image) { + imageUrl = parsedMetadata.image; + if (imageUrl.startsWith('ipfs://')) { + imageUrl = `https://ipfs.io/ipfs/${imageUrl.slice(7)}`; + } + } + } catch (e) { + console.warn('Error parsing NFT metadata:', e); + } + } + + // Get attributes safely - checking all possible paths + let attributes = []; + if (nft.normalized_metadata && Array.isArray(nft.normalized_metadata.attributes)) { + attributes = nft.normalized_metadata.attributes; + } else if (nft.metadata) { + try { + const parsedMetadata = typeof nft.metadata === 'string' + ? JSON.parse(nft.metadata) + : nft.metadata; + + if (Array.isArray(parsedMetadata.attributes)) { + attributes = parsedMetadata.attributes; + } + } catch (e) { + // Ignore parsing errors for attributes + } + } + + return { + id: `${nft.token_address.toLowerCase()}-${nft.token_id}`, + tokenId: nft.token_id, + name: nft.normalized_metadata?.name || `NFT #${nft.token_id}`, + description: nft.normalized_metadata?.description || '', + imageUrl: imageUrl, + attributes: attributes, + chain: chainId + }; +} -const MORALIS_API_KEY = process.env.MORALIS_API_KEY; const BASE_URL = 'https://deep-index.moralis.io/api/v2'; interface MoralisChain { diff --git a/lib/api/nftContracts.ts b/lib/api/nftContracts.ts index d9ecd02..bafd51d 100644 --- a/lib/api/nftContracts.ts +++ b/lib/api/nftContracts.ts @@ -20,7 +20,15 @@ const ERC1155_ABI = [ "function balanceOfBatch(address[] accounts, uint256[] ids) view returns (uint256[])", "function uri(uint256 id) view returns (string)", "function name() view returns (string)", - "function symbol() view returns (string)" + "function symbol() view returns (string)", + "event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value)", + "event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values)" +]; + +// Extended Event ABI for BSC +const BSC_EVENT_ABI = [ + "event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value)", + "event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values)" ]; // Simplified interface for NFTs @@ -60,10 +68,35 @@ export function getNFTContract( /** * Check if a contract implements ERC-721 or ERC-1155 */ -export async function detectNFTStandard(contractAddress: string, chainId: string): Promise<'ERC721' | 'ERC1155' | 'UNKNOWN'> { +const BSCSCAN_API_KEY = process.env.BSCSCAN_API_KEY || '1QGN2GHNEPT6CQP854TVBH24C85714ETC5'; + +export async function detectNFTStandard(contractAddress: string, chainId: string): Promise<'ERC721' | 'BNB721' | 'ERC1155' | 'UNKNOWN'> { try { const provider = getChainProvider(chainId); + // For BNB Chain, try BscScan API first + if (chainId === '0x38' || chainId === '0x61') { + try { + const baseUrl = chainId === '0x38' ? 'https://api.bscscan.com/api' : 'https://api-testnet.bscscan.com/api'; + const response = await fetch(`${baseUrl}?module=contract&action=getabi&address=${contractAddress}&apikey=${BSCSCAN_API_KEY}`); + const data = await response.json(); + + if (data.status === '1' && data.result) { + const abi = JSON.parse(data.result); + const hasNFTMethods = abi.some((item: any) => + (item.name === 'tokenURI' || item.name === 'balanceOf') && + item.type === 'function' + ); + + if (hasNFTMethods) { + return 'BNB721'; // BNB Chain's NFT standard + } + } + } catch (err) { + console.warn("BSCScan API error:", err); + } + } + // Create an interface to test for ERC-165 supportsInterface function const erc165Interface = new ethers.utils.Interface([ "function supportsInterface(bytes4 interfaceId) view returns (bool)" @@ -77,7 +110,7 @@ export async function detectNFTStandard(contractAddress: string, chainId: string try { const isERC721 = await contract.supportsInterface(ERC721_INTERFACE_ID); - if (isERC721) return 'ERC721'; + if (isERC721) return chainId === '0x38' || chainId === '0x61' ? 'BNB721' : 'ERC721'; const isERC1155 = await contract.supportsInterface(ERC1155_INTERFACE_ID); if (isERC1155) return 'ERC1155'; @@ -89,7 +122,7 @@ export async function detectNFTStandard(contractAddress: string, chainId: string try { const erc721Contract = new ethers.Contract(contractAddress, ['function name() view returns (string)'], provider); await erc721Contract.name(); - return 'ERC721'; + return chainId === '0x38' || chainId === '0x61' ? 'BNB721' : 'ERC721'; } catch { return 'UNKNOWN'; } @@ -100,6 +133,49 @@ export async function detectNFTStandard(contractAddress: string, chainId: string } } +// Simple in-memory cache for BSCScan responses to avoid hitting rate limits +const contractResponseCache = new Map(); +const CONTRACT_CACHE_TTL = 300000; // 5 minute cache TTL + +// Helper function to make BSCScan API calls with caching +async function cachedBscScanApiCall(params: Record, chainId: string): Promise { + const cacheKey = JSON.stringify(params) + chainId; + const cachedResponse = contractResponseCache.get(cacheKey); + + // Return cached response if valid + if (cachedResponse && (Date.now() - cachedResponse.timestamp) < CONTRACT_CACHE_TTL) { + return cachedResponse.data; + } + + // Build the URL for BSCScan API + const baseUrl = chainId === '0x38' ? 'https://api.bscscan.com/api' : 'https://api-testnet.bscscan.com/api'; + const queryParams = new URLSearchParams({ + ...params, + apikey: BSCSCAN_API_KEY + }); + + try { + const response = await fetch(`${baseUrl}?${queryParams.toString()}`); + + if (!response.ok) { + throw new Error(`BSCScan API request failed with status ${response.status}`); + } + + const data = await response.json(); + + // Cache the successful response + contractResponseCache.set(cacheKey, { + data, + timestamp: Date.now() + }); + + return data; + } catch (error) { + console.error("BSCScan API request failed:", error); + throw error; + } +} + /** * Get the collection info for an NFT contract */ @@ -173,14 +249,46 @@ export async function fetchNFTData(contractAddress: string, tokenId: string, cha throw new Error('Contract does not appear to be an NFT collection'); } + // For BNB Chain, try BSCScan API first + if ((nftStandard === 'BNB721' || chainId === '0x38' || chainId === '0x61')) { + try { + const baseUrl = chainId === '0x38' ? 'https://api.bscscan.com/api' : 'https://api-testnet.bscscan.com/api'; + const response = await fetch( + `${baseUrl}?module=token&action=tokenuri&contractaddress=${contractAddress}&tokenid=${tokenId}&apikey=${BSCSCAN_API_KEY}` + ); + const data = await response.json(); + + if (data.status === '1' && data.result) { + const tokenURI = data.result; + const metadata = await fetchMetadata(tokenURI); + + if (metadata) { + return { + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId, + name: metadata.name || `Token #${tokenId}`, + description: metadata.description || '', + imageUrl: resolveContentUrl(metadata.image || metadata.image_url || ''), + attributes: metadata.attributes || [], + chain: chainId + }; + } + } + } catch (err) { + console.warn("BSCScan API error:", err); + // Fall back to direct contract call + } + } + + // If BSCScan API fails or for other chains, try direct contract call const contract = getNFTContract(contractAddress, provider, nftStandard === 'ERC1155'); // Get token metadata URI let tokenURI; try { - tokenURI = nftStandard === 'ERC721' - ? await contract.tokenURI(tokenId) - : await contract.uri(tokenId); + tokenURI = nftStandard === 'ERC1155' + ? await contract.uri(tokenId) + : await contract.tokenURI(tokenId); // Some contracts return the base URI and require appending the token ID if (tokenURI.includes('{id}')) { @@ -189,6 +297,11 @@ export async function fetchNFTData(contractAddress: string, tokenId: string, cha } } catch (err) { console.error("Error fetching token URI:", err); + + // For BNB Chain, provide more specific error message + if (nftStandard === 'BNB721') { + toast.error("Failed to fetch BNB NFT data. Please check if the token exists."); + } return null; } @@ -211,7 +324,13 @@ export async function fetchNFTData(contractAddress: string, tokenId: string, cha }; } catch (error) { console.error("Error fetching NFT data:", error); - toast.error("Failed to fetch NFT data"); + + // Provide more specific error messages for BNB Chain + if (chainId === '0x38' || chainId === '0x61') { + toast.error("Failed to fetch BNB NFT. Please check the contract address and token ID."); + } else { + toast.error("Failed to fetch NFT data"); + } return null; } } @@ -266,69 +385,216 @@ export function resolveContentUrl(uri: string): string { * Fetch a batch of NFTs for a collection */ export async function fetchContractNFTs( - contractAddress: string, - chainId: string, - startIndex: number = 0, + contractAddress: string, + chainId: string, + startIndex: number = 0, count: number = 20 ): Promise { try { + // Get provider for the specified chain const provider = getChainProvider(chainId); - const nftStandard = await detectNFTStandard(contractAddress, chainId); - if (nftStandard === 'UNKNOWN') { - throw new Error('Contract does not appear to be an NFT collection'); - } - - if (nftStandard === 'ERC1155') { - // For ERC1155, we need a different approach since there's no simple enumeration - // We'd need to rely on events, external APIs, or known token IDs - throw new Error('Batch fetching for ERC1155 not implemented'); - } - - const contract = getNFTContract(contractAddress, provider); + // Try to determine if this is an ERC721 or ERC1155 contract + // But handle contracts that don't implement supportsInterface + const interfaceSupport = { + erc721: false, + erc1155: false + }; - // For ERC721 try { - // Check if the contract supports enumeration - const supportsEnumeration = await contract.supportsInterface('0x780e9d63'); - - if (!supportsEnumeration) { - throw new Error('Contract does not support enumeration'); - } - - const totalSupply = await contract.totalSupply(); + // ERC165 interface ID for ERC721 and ERC1155 + const ERC721_INTERFACE_ID = '0x80ac58cd'; + const ERC1155_INTERFACE_ID = '0xd9b67a26'; - // Make sure we don't try to fetch beyond the total supply - const endIndex = Math.min(startIndex + count, totalSupply.toNumber()); + const supportsInterfaceAbi = [ + "function supportsInterface(bytes4 interfaceId) view returns (bool)" + ]; - // Fetch token IDs - const fetchPromises = []; - for (let i = startIndex; i < endIndex; i++) { - fetchPromises.push(contract.tokenByIndex(i)); - } - - const tokenIds = await Promise.all(fetchPromises); - - // Fetch metadata for each token - const nftPromises = tokenIds.map(tokenId => - fetchNFTData(contractAddress, tokenId.toString(), chainId) + const interfaceContract = new ethers.Contract( + contractAddress, + supportsInterfaceAbi, + provider ); - const nfts = await Promise.all(nftPromises); + // Try to check interfaces - but don't fail if not supported + try { + interfaceSupport.erc721 = await interfaceContract.supportsInterface(ERC721_INTERFACE_ID); + } catch (e) { + // Contract doesn't support supportsInterface, assume ERC721 + interfaceSupport.erc721 = true; + } - // Filter out null results - return nfts.filter(nft => nft !== null) as NFTMetadata[]; - } catch (err) { - console.error("Error enumerating NFTs:", err); - throw new Error('Failed to enumerate NFTs'); + try { + interfaceSupport.erc1155 = await interfaceContract.supportsInterface(ERC1155_INTERFACE_ID); + } catch (e) { + // Contract doesn't support supportsInterface + interfaceSupport.erc1155 = false; + } + } catch (e) { + console.warn('Error checking contract interface support:', e); + // Fallback to assuming ERC721 + interfaceSupport.erc721 = true; + } + + // Determine contract type based on interface support or fallback to ERC721 + let contractType = 'erc721'; + if (interfaceSupport.erc1155) { + contractType = 'erc1155'; + } + + // Different fetching strategy based on contract type + if (contractType === 'erc721') { + return await fetchERC721NFTs(contractAddress, chainId, provider, startIndex, count); + } else { + return await fetchERC1155NFTs(contractAddress, chainId, provider, startIndex, count); } } catch (error) { - console.error("Error batch fetching NFTs:", error); - toast.error("Failed to fetch NFTs from contract"); + console.error('Error enumerating NFTs:', error); + // Return empty array instead of propagating error return []; } } +/** + * Fetch ERC721 NFTs with fallback strategies + */ +async function fetchERC721NFTs( + contractAddress: string, + chainId: string, + provider: ethers.providers.Provider, + startIndex: number, + count: number +): Promise { + // Combined ABI with all the functions we might need + const abi = [ + // ERC721 Enumerable functions + "function totalSupply() view returns (uint256)", + "function tokenByIndex(uint256 index) view returns (uint256)", + // Regular ERC721 functions + "function balanceOf(address owner) view returns (uint256)", + "function ownerOf(uint256 tokenId) view returns (address)", + "function tokenURI(uint256 tokenId) view returns (string)", + // Some contracts use tokenOfOwnerByIndex + "function tokenOfOwnerByIndex(address owner, uint256 index) view returns (uint256)" + ]; + + const contract = new ethers.Contract(contractAddress, abi, provider); + + let tokenIds: string[] = []; + + try { + // Try to use ERC721Enumerable if supported + const totalSupply = await contract.totalSupply(); + + // Make sure we don't exceed the total supply + const endIndex = Math.min(startIndex + count, totalSupply.toNumber()); + + // Get tokenIds from indexes + const tokenIdPromises = []; + for (let i = startIndex; i < endIndex; i++) { + tokenIdPromises.push(contract.tokenByIndex(i).then((id: any) => id.toString())); + } + + tokenIds = await Promise.all(tokenIdPromises); + } catch (e) { + console.warn('Contract does not support ERC721Enumerable, trying alternative approach:', e); + + // If the contract doesn't support enumeration, try a sequential approach + // Try with a range of token IDs in the expected range + const maxId = startIndex + count * 10; // Try a wider range to find valid tokens + const checkPromises = []; + + for (let i = startIndex; i < maxId && tokenIds.length < count; i++) { + checkPromises.push( + (async () => { + try { + // Check if this tokenId exists by calling ownerOf + await contract.ownerOf(i); + return i.toString(); + } catch { + // Token doesn't exist or is burned + return null; + } + })() + ); + + // Process in smaller batches to avoid overloading the provider + if (checkPromises.length >= 10 || i === maxId - 1) { + const results = await Promise.all(checkPromises); + tokenIds.push(...results.filter(Boolean) as string[]); + checkPromises.length = 0; + } + } + + // Only use the requested count + tokenIds = tokenIds.slice(0, count); + } + + // Now fetch metadata for each token + const nftsPromises = tokenIds.map(async (tokenId) => { + try { + let tokenURI = ''; + try { + tokenURI = await contract.tokenURI(tokenId); + } catch (e) { + console.warn(`Error getting tokenURI for ${tokenId}:`, e); + } + + let metadata: any = {}; + if (tokenURI) { + try { + // Handle IPFS URIs + const metadataUrl = tokenURI.replace('ipfs://', 'https://ipfs.io/ipfs/'); + const metadataResponse = await fetch(metadataUrl); + if (metadataResponse.ok) { + metadata = await metadataResponse.json(); + } + } catch (e) { + console.warn(`Error fetching metadata for token ${tokenId}:`, e); + } + } + + return { + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId, + name: metadata.name || `NFT #${tokenId}`, + description: metadata.description || '', + imageUrl: metadata.image || '', + attributes: metadata.attributes || [], + chain: chainId + }; + } catch (e) { + console.warn(`Error processing token ${tokenId}:`, e); + return { + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId, + name: `NFT #${tokenId}`, + description: '', + imageUrl: '', + attributes: [], + chain: chainId + }; + } + }); + + return await Promise.all(nftsPromises); +} + +/** + * Fetch ERC1155 NFTs + */ +async function fetchERC1155NFTs( + contractAddress: string, + chainId: string, + provider: ethers.providers.Provider, + startIndex: number, + count: number +): Promise { + // ERC1155 doesn't have a standard enumeration mechanism + // We'll make a best effort to get token IDs from known collections + return []; // Implement ERC1155 support if needed +} + /** * Check if an address owns a specific NFT */ @@ -386,49 +652,213 @@ export async function fetchOwnedNFTs( if (nftStandard === 'UNKNOWN') { throw new Error('Contract does not appear to be an NFT collection'); } - - const contract = getNFTContract(contractAddress, provider, nftStandard === 'ERC1155'); - - if (nftStandard === 'ERC721') { + + // For BNB Chain, try BSCScan API first + if (nftStandard === 'BNB721' || chainId === '0x38' || chainId === '0x61') { try { - // Check if contract supports enumeration - const supportsEnumeration = await contract.supportsInterface('0x780e9d63'); + const baseUrl = chainId === '0x38' ? 'https://api.bscscan.com/api' : 'https://api-testnet.bscscan.com/api'; + const response = await fetch( + `${baseUrl}?module=token&action=tokennfttx&address=${ownerAddress}&contractaddress=${contractAddress}&apikey=${BSCSCAN_API_KEY}` + ); + const data = await response.json(); - if (!supportsEnumeration) { - throw new Error('Contract does not support enumeration'); + if (data.status === '1' && data.result) { + interface BSCNFTTransaction { + tokenID: string; + to: string; + from: string; + } + + const transactions = data.result as BSCNFTTransaction[]; + const ownedTokenIds = transactions + .filter(tx => tx.to.toLowerCase() === ownerAddress.toLowerCase()) + .filter(tx => !transactions.some( + outTx => outTx.tokenID === tx.tokenID && + outTx.from.toLowerCase() === ownerAddress.toLowerCase() + )) + .map(tx => tx.tokenID); + + const uniqueTokenIds = [...new Set(ownedTokenIds)]; + const nftPromises = uniqueTokenIds.map(tokenId => + fetchNFTData(contractAddress, tokenId, chainId) + ); + + const nfts = await Promise.all(nftPromises); + return nfts.filter(nft => nft !== null) as NFTMetadata[]; } - + } catch (err) { + console.warn("BSCScan API error:", err); + // Fall back to direct contract calls + } + } + + // If BSCScan API fails or for other chains, try direct contract calls + const contract = getNFTContract(contractAddress, provider, nftStandard === 'ERC1155'); + + if (nftStandard === 'ERC721' || nftStandard === 'BNB721') { + try { const balance = await contract.balanceOf(ownerAddress); if (balance.eq(0)) { return []; } - // Fetch token IDs owned by the address - const fetchPromises = []; - for (let i = 0; i < balance.toNumber(); i++) { - fetchPromises.push(contract.tokenOfOwnerByIndex(ownerAddress, i)); + try { + const supportsEnumeration = await contract.supportsInterface('0x780e9d63'); + + if (supportsEnumeration) { + const fetchPromises = []; + for (let i = 0; i < balance.toNumber(); i++) { + fetchPromises.push(contract.tokenOfOwnerByIndex(ownerAddress, i)); + } + + const tokenIds = await Promise.all(fetchPromises); + const nftPromises = tokenIds.map(tokenId => + fetchNFTData(contractAddress, tokenId.toString(), chainId) + ); + + const nfts = await Promise.all(nftPromises); + return nfts.filter(nft => nft !== null) as NFTMetadata[]; + } + } catch (enumError) { + console.warn("Enumeration not supported, scanning all tokens"); + } + + // Fallback: Scan all tokens + try { + const totalSupply = await contract.totalSupply(); + const nftsFound: NFTMetadata[] = []; + + // Scan in batches to avoid timeout + const batchSize = 20; + for (let i = 0; i < totalSupply.toNumber(); i += batchSize) { + const end = Math.min(i + batchSize, totalSupply.toNumber()); + const checkPromises = []; + + for (let tokenId = i; tokenId < end; tokenId++) { + checkPromises.push( + contract.ownerOf(tokenId) + .then((owner: string) => owner.toLowerCase() === ownerAddress.toLowerCase() ? tokenId : null) + .catch(() => null) + ); + } + + const results = await Promise.all(checkPromises); + const validTokenIds = results.filter((id): id is number => id !== null); + + if (validTokenIds.length > 0) { + const nftDataPromises = validTokenIds.map(tokenId => + fetchNFTData(contractAddress, tokenId.toString(), chainId) + ); + const batchNFTs = await Promise.all(nftDataPromises); + nftsFound.push(...batchNFTs.filter(nft => nft !== null) as NFTMetadata[]); + } + } + + return nftsFound; + } catch (scanError) { + console.error("Error scanning for owned NFTs:", scanError); + throw new Error(nftStandard === 'BNB721' + ? 'Failed to fetch owned BNB NFTs' + : 'Failed to enumerate owned NFTs' + ); } - - const tokenIds = await Promise.all(fetchPromises); - - // Fetch metadata for each token - const nftPromises = tokenIds.map(tokenId => - fetchNFTData(contractAddress, tokenId.toString(), chainId) - ); - - const nfts = await Promise.all(nftPromises); - - // Filter out null results - return nfts.filter(nft => nft !== null) as NFTMetadata[]; } catch (err) { console.error("Error enumerating owned NFTs:", err); throw new Error('Failed to enumerate owned NFTs'); } } else if (nftStandard === 'ERC1155') { - // For ERC1155, we need a different approach - // We'd need to rely on events, external APIs, or known token IDs - throw new Error('Owned NFT fetching for ERC1155 not implemented directly'); + // Special handling for ERC1155 tokens + try { + // For BSC chain, try BSCScan API first + if (chainId === '0x38' || chainId === '0x61') { + try { + const baseUrl = chainId === '0x38' ? 'https://api.bscscan.com/api' : 'https://api-testnet.bscscan.com/api'; + const response = await fetch( + `${baseUrl}?module=account&action=token1155tx&address=${ownerAddress}&contractaddress=${contractAddress}&apikey=${BSCSCAN_API_KEY}` + ); + const data = await response.json(); + + if (data.status === '1' && data.result) { + // Process BSCScan ERC1155 transactions + const tokenIds = new Set(); + for (const tx of data.result) { + if (tx.to.toLowerCase() === ownerAddress.toLowerCase()) { + tokenIds.add(tx.tokenID); + } + } + + // Check current balance for each token ID + const activeTokens = await Promise.all( + Array.from(tokenIds).map(async tokenId => { + const balance = await contract.balanceOf(ownerAddress, tokenId); + return balance.gt(0) ? tokenId : null; + }) + ); + + const nftPromises = activeTokens + .filter((id): id is string => id !== null) + .map(tokenId => fetchNFTData(contractAddress, tokenId, chainId)); + + const nfts = await Promise.all(nftPromises); + return nfts.filter(nft => nft !== null) as NFTMetadata[]; + } + } catch (bscError) { + console.warn("BSCScan ERC1155 error:", bscError); + // Fall back to events + } + } + + // Using an extended contract instance for events + const eventContract = new ethers.Contract( + contractAddress, + [...ERC1155_ABI, ...BSC_EVENT_ABI], + getChainProvider(chainId) + ); + + // Get both single and batch transfer events + const [singleTransfers, batchTransfers] = await Promise.all([ + eventContract.queryFilter(eventContract.filters.TransferSingle(null, null, ownerAddress)), + eventContract.queryFilter(eventContract.filters.TransferBatch(null, null, ownerAddress)) + ]); + + // Process both types of transfers + const tokenIds = new Set(); + + singleTransfers.forEach(event => { + if (event.args?.id) { + tokenIds.add(event.args.id.toString()); + } + }); + + batchTransfers.forEach(event => { + if (event.args?.ids) { + event.args.ids.forEach((id: ethers.BigNumber) => { + tokenIds.add(id.toString()); + }); + } + }); + + // Check current balance for each token ID + const activeTokens = await Promise.all( + Array.from(tokenIds).map(async tokenId => { + const balance = await contract.balanceOf(ownerAddress, tokenId); + return balance.gt(0) ? tokenId : null; + }) + ); + + // Fetch metadata for tokens with non-zero balance + const nftPromises = activeTokens + .filter((id): id is string => id !== null) + .map(tokenId => fetchNFTData(contractAddress, tokenId, chainId)); + + const nfts = await Promise.all(nftPromises); + return nfts.filter(nft => nft !== null) as NFTMetadata[]; + } catch (error) { + console.error("Error fetching ERC1155 tokens:", error); + toast.error("Failed to fetch ERC1155 tokens. Please try again."); + return []; + } } return []; @@ -443,7 +873,6 @@ export async function fetchOwnedNFTs( * Get popular NFT collections for a specific chain */ export const POPULAR_NFT_COLLECTIONS = { - // Ethereum collections '0x1': [ { address: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', @@ -462,44 +891,16 @@ export const POPULAR_NFT_COLLECTIONS = { name: 'Mutant Ape Yacht Club', description: 'The MUTANT APE YACHT CLUB is a collection of up to 20,000 Mutant Apes.', standard: 'ERC721' - }, - { - address: '0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e', - name: 'Doodles', - description: 'A community-driven collectibles project featuring art by Burnt Toast.', - standard: 'ERC721' - }, - { - address: '0x34d85c9CDeB23FA97cb08333b511ac86E1C4E258', - name: 'Otherdeed for Otherside', - description: 'Otherdeeds are the key to claiming land in Otherside.', - standard: 'ERC721' } ], - - // BNB Chain collections '0x38': [ - { - address: '0xDf7952B35f24aCF7fC0487D01c8d5690a60DBa07', - name: 'Pancake Bunnies', - description: 'Pancake Bunnies are NFTs created by PancakeSwap.', - standard: 'ERC721' - }, { address: '0x0a8901b0E25DEb55A87524f0cC164E9644020EBA', name: 'Pancake Squad', - description: 'PancakeSwap\'s NFT collection of 10,000 unique bunnies.', - standard: 'ERC721' - }, - { - address: '0x85F0e02cb992aa1F9F47112F815F519EF1A59E2D', - name: 'BNB Bulls Club', - description: 'The BNB Bulls Club is a collection of 10,000 unique NFTs on the BNB Chain.', - standard: 'ERC721' + description: 'PancakeSwap\'s NFT collection for the BSC community.', + standard: 'BNB721' } ], - - // Sepolia testnet (demo collections) '0xaa36a7': [ { address: '0x7C09282C24C363073E0f30D74C301C312E5533AC', @@ -508,31 +909,39 @@ export const POPULAR_NFT_COLLECTIONS = { standard: 'ERC721' } ], - - // BNB Testnet - including our mock CryptoPath collection '0x61': [ { address: '0x2fF12fE4B3C4DEa244c4BdF682d572A90Df3B551', name: 'CryptoPath Genesis', description: 'The official NFT collection of the CryptoPath ecosystem with exclusive benefits.', - standard: 'ERC721' + standard: 'BNB721' + }, + { + address: '0x60935F36e4631F73f0f407e68642144e07aC7f5E', + name: 'BSC Test Collection', + description: 'Test NFT collection with both BNB721 and ERC1155 tokens.', + standard: 'ERC1155' } ] -}; +} as const; /** * Helper function to get contract examples for educational purposes */ -export function getExampleNFTContract(chainId: string): string { +export function getExampleNFTContract(chainId: string, standard: 'ERC721' | 'BNB721' | 'ERC1155' = 'ERC721'): string { switch (chainId) { case '0x1': return '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D'; // BAYC on Ethereum case '0xaa36a7': return '0x7C09282C24C363073E0f30D74C301C312E5533AC'; // Test NFT on Sepolia case '0x38': - return '0x0a8901b0E25DEb55A87524f0cC164E9644020EBA'; // PancakeSquad on BNB Chain + return standard === 'ERC1155' + ? '0xDf7952B35f24aCF7fC0487D01c8d5690a60DBa07' // BSC Multi-Token + : '0x0a8901b0E25DEb55A87524f0cC164E9644020EBA'; // Pancake Squad with correct address case '0x61': - return '0x2fF12fE4B3C4DEa244c4BdF682d572A90Df3B551'; // CryptoPath Genesis on BNB Testnet + return standard === 'ERC1155' + ? '0x60935F36e4631F73f0f407e68642144e07aC7f5E' // BSC Test Collection + : '0x2fF12fE4B3C4DEa244c4BdF682d572A90Df3B551'; // CryptoPath Genesis default: return '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D'; // Default to BAYC } diff --git a/lib/api/nftMockData.ts b/lib/api/nftMockData.ts new file mode 100644 index 0000000..04bb301 --- /dev/null +++ b/lib/api/nftMockData.ts @@ -0,0 +1,140 @@ +/** + * This file contains functions to generate mock NFT data + * Used as fallbacks when API calls fail or for demo/testing purposes + */ + +// Mock collection data - maps contract addresses to collection info +const mockCollectionsMap: Record = { + // BNB Testnet CryptoPath collection + '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551': { + name: 'CryptoPath Genesis', + description: 'The official NFT collection of the CryptoPath ecosystem. These limited edition NFTs grant exclusive access to premium features and rewards within the CryptoPath platform.', + imageUrl: '/Img/logo/cryptopath.png', + totalSupply: '1000', + symbol: 'CPG', + chain: '0x61', + verified: true, + category: 'Utility', + featured: true + }, + + // Ethereum example collections + '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d': { + name: 'Bored Ape Yacht Club', + description: 'The Bored Ape Yacht Club is a collection of 10,000 unique Bored Ape NFTs— unique digital collectibles living on the Ethereum blockchain.', + imageUrl: 'https://i.seadn.io/gae/Ju9CkWtV-1Okvf45wo8UctR-M9He2PjILP0oOvxE89AyiPPGtrR3gysu1Zgy0hjd2xKIgjJJtWIc0ybj4Vd7wv8t3pxDGHoJBzDB?auto=format&dpr=1&w=1000', + totalSupply: '10000', + symbol: 'BAYC', + chain: '0x1', + verified: true, + category: 'Art' + }, + + // BNB Chain example collections + '0x0a8901b0e25deb55a87524f0cc164e9644020eba': { + name: 'Pancake Squad', + description: 'PancakeSwap\'s NFT collection of 10,000 unique bunnies designed to reward loyal community members and bring utility to the CAKE token.', + imageUrl: 'https://i.seadn.io/s/raw/files/8b1d3939c420d39c8914f68b506c50db.png?auto=format&dpr=1&w=256', + totalSupply: '10000', + symbol: 'PS', + chain: '0x38', + verified: true, + category: 'Gaming' + } +}; + +/** + * Generate a mock NFT collection when API calls fail + */ +export function generateMockNFTCollection(contractAddress: string, chainId: string) { + // Normalize contract address for comparison + const normalizedAddress = contractAddress.toLowerCase(); + + // Check if we have specific mock data for this collection + if (mockCollectionsMap[normalizedAddress]) { + return mockCollectionsMap[normalizedAddress]; + } + + // Generate generic mock data based on chain + const chainName = chainId === '0x1' ? 'Ethereum' : + chainId === '0xaa36a7' ? 'Sepolia' : + chainId === '0x38' ? 'BNB Chain' : 'BNB Testnet'; + + return { + name: `Collection ${contractAddress.slice(0, 6)}`, + description: `A sample NFT collection on ${chainName}`, + imageUrl: '/Img/nft/sample-1.jpg', + totalSupply: '1000', + symbol: 'NFT', + chain: chainId, + verified: false, + category: 'Collectibles' + }; +} + +/** + * Generate mock NFTs for testing or when API calls fail + */ +export function generateMockNFTs(contractAddress: string, chainId: string, page: number, pageSize: number): any[] { + const nfts: any[] = []; + const startIndex = (page - 1) * pageSize + 1; + + // Normalized contract address + const normalizedAddress = contractAddress.toLowerCase(); + + // Get collection info if available + const collectionInfo = mockCollectionsMap[normalizedAddress] || { + name: `Collection ${contractAddress.slice(0, 6)}`, + chain: chainId + }; + + // Generate NFTs for this page + for (let i = 0; i < pageSize; i++) { + const tokenId = String(startIndex + i); + + // Generate deterministic but varied attributes based on token ID + const tokenNum = parseInt(tokenId, 10); + const seed = tokenNum % 100; + + // Background options + const backgrounds = ['Blue', 'Red', 'Green', 'Purple', 'Gold', 'Black', 'White']; + const backgroundIndex = seed % backgrounds.length; + + // Species options + const species = ['Human', 'Ape', 'Robot', 'Alien', 'Zombie', 'Demon', 'Angel']; + const speciesIndex = (seed * 3) % species.length; + + // Rarity options + const rarities = ['Common', 'Uncommon', 'Rare', 'Epic', 'Legendary']; + const rarityIndex = Math.floor(seed / 20); // 0-4 + + // For BNB Testnet CryptoPath collection, use special naming + const name = normalizedAddress === '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551' + ? `CryptoPath Genesis #${tokenId}` + : `${collectionInfo.name} #${tokenId}`; + + const description = normalizedAddress === '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551' + ? `A unique NFT from the CryptoPath Genesis Collection with ${rarities[rarityIndex]} rarity.` + : `NFT #${tokenId} from ${collectionInfo.name}`; + + nfts.push({ + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId: tokenId, + name: name, + description: description, + imageUrl: `/Img/nft/sample-${(seed % 5) + 1}.jpg`, // Using sample images 1-5 + attributes: [ + { trait_type: 'Background', value: backgrounds[backgroundIndex] }, + { trait_type: 'Species', value: species[speciesIndex] }, + { trait_type: 'Rarity', value: rarities[rarityIndex] }, + // Network attribute for filtering + { trait_type: 'Network', value: chainId === '0x1' ? 'Ethereum' : + chainId === '0xaa36a7' ? 'Sepolia' : + chainId === '0x38' ? 'BNB Chain' : 'BNB Testnet' } + ], + chain: chainId + }); + } + + return nfts; +} diff --git a/lib/api/nftService.ts b/lib/api/nftService.ts index c3e6ae2..5a13450 100644 --- a/lib/api/nftService.ts +++ b/lib/api/nftService.ts @@ -5,19 +5,22 @@ import { fetchContractCollectionInfo, fetchNFTData, fetchContractNFTs, - fetchOwnedNFTs, NFTMetadata, POPULAR_NFT_COLLECTIONS } from './nftContracts'; import { CollectionNFT, - CollectionNFTsResponse + CollectionNFTsResponse, + fetchCollectionInfo as _alchemyFetchCollectionInfo, + fetchCollectionNFTs as alchemyFetchCollectionNFTs } from './alchemyNFTApi'; -import { getChainProvider, getExplorerUrl, ChainConfig, chainConfigs } from './chainProviders'; +import { getChainProvider, getExplorerUrl, chainConfigs } from './chainProviders'; // Environment variables for API keys const ALCHEMY_API_KEY = process.env.NEXT_PUBLIC_ALCHEMY_API_KEY || 'demo'; const MORALIS_API_KEY = process.env.NEXT_PUBLIC_MORALIS_API_KEY || ''; +const ETHERSCAN_API_KEY = process.env.NEXT_PUBLIC_ETHERSCAN_API_KEY || ''; +const BSCSCAN_API_KEY = process.env.NEXT_PUBLIC_BSCSCAN_API_KEY || ''; // Default pagination settings const DEFAULT_PAGE_SIZE = 20; @@ -33,7 +36,7 @@ const nftCache = new Map(); // Cache TTL in milliseconds (10 minutes) -const CACHE_TTL = 10 * 60 * 1000; +const COLLECTION_CACHE_TTL = 10 * 60 * 1000; /** * Chain ID to network mapping for API endpoints @@ -76,6 +79,40 @@ export interface CollectionMetadata { twitter?: string; } +/** + * API availability tracking to manage fallbacks + */ +interface ApiStatus { + alchemy: boolean; + moralis: boolean; + etherscan: boolean; + bscscan: boolean; + lastChecked: number; +} + +// Track API health to manage fallbacks +const apiStatus: ApiStatus = { + alchemy: true, + moralis: true, + etherscan: true, + bscscan: true, + lastChecked: 0 +}; + +/** + * Check if a chain is BNB/BSC-based + */ +function isBNBChain(chainId: string): boolean { + return chainId === '0x38' || chainId === '0x61'; +} + +/** + * Check if a chain is Ethereum-based + */ +function isEthereumChain(chainId: string): boolean { + return chainId === '0x1' || chainId === '0xaa36a7' || chainId === '0x5'; +} + /** * Fetch NFT collection information with caching */ @@ -92,54 +129,97 @@ export async function fetchCollectionInfo(contractAddress: string, chainId: stri // Try to fetch from blockchain first const contractInfo = await fetchContractCollectionInfo(contractAddress, chainId); - // Try Alchemy for additional metadata - let alchemyData = null; - try { - const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; - const apiUrl = `https://${network}.g.alchemy.com/nft/v2/${ALCHEMY_API_KEY}/getContractMetadata`; - const url = new URL(apiUrl); - url.searchParams.append('contractAddress', contractAddress); - - const response = await fetch(url.toString()); - if (response.ok) { - alchemyData = await response.json(); - } - } catch (err) { - console.warn("Alchemy metadata fetch failed:", err); - } - - // Try marketplace data lookup for floor price, etc. - const marketData = await fetchMarketplaceData(contractAddress, chainId); - - // Combine all data sources - const metadata: CollectionMetadata = { + let metadata: Partial = { id: contractAddress.toLowerCase(), name: contractInfo.name || 'Unknown Collection', symbol: contractInfo.symbol || '', - description: alchemyData?.contractMetadata?.openSea?.description || '', - imageUrl: alchemyData?.contractMetadata?.openSea?.imageUrl || '/fallback-collection-logo.png', - bannerImageUrl: alchemyData?.contractMetadata?.openSea?.bannerImageUrl || '', + description: '', + imageUrl: '/fallback-collection-logo.png', totalSupply: contractInfo.totalSupply || '0', - floorPrice: marketData?.floorPrice || '0', - volume24h: marketData?.volume24h || '0', chain: chainId, contractAddress: contractAddress.toLowerCase(), - verified: alchemyData?.contractMetadata?.openSea?.safelistRequestStatus === 'verified', - category: alchemyData?.contractMetadata?.openSea?.category || 'Art', - featured: false, standard: contractInfo.standard || 'ERC721', - creatorAddress: alchemyData?.contractMetadata?.openSea?.creator || '', - website: alchemyData?.contractMetadata?.openSea?.externalUrl || '', - discord: alchemyData?.contractMetadata?.openSea?.discordUrl || '', - twitter: alchemyData?.contractMetadata?.openSea?.twitterUsername - ? `https://twitter.com/${alchemyData.contractMetadata.openSea.twitterUsername}` - : '', }; + // Set API fallback order based on chain + if (isBNBChain(chainId)) { + // BNB Chain: Try Moralis -> BSCScan -> Contract fallback + if (apiStatus.moralis) { + try { + const moralisData = await fetchCollectionInfoFromMoralis(contractAddress, chainId); + metadata = { ...metadata, ...moralisData }; + } catch (error) { + console.warn("Moralis metadata fetch failed:", error); + apiStatus.moralis = false; + apiStatus.lastChecked = Date.now(); + } + } + + if (apiStatus.bscscan && (!metadata.description || !metadata.imageUrl)) { + try { + const bscscanData = await fetchCollectionInfoFromBSCScan(contractAddress, chainId); + metadata = { ...metadata, ...bscscanData }; + } catch (error) { + console.warn("BSCScan metadata fetch failed:", error); + apiStatus.bscscan = false; + apiStatus.lastChecked = Date.now(); + } + } + } else { + // Ethereum: Try Alchemy -> Moralis -> Etherscan -> Contract fallback + if (apiStatus.alchemy) { + try { + const alchemyData = await fetchCollectionInfoFromAlchemy(contractAddress, chainId); + metadata = { ...metadata, ...alchemyData }; + } catch (error) { + console.warn("Alchemy metadata fetch failed:", error); + apiStatus.alchemy = false; + apiStatus.lastChecked = Date.now(); + } + } + + if (apiStatus.moralis && (!metadata.description || !metadata.imageUrl)) { + try { + const moralisData = await fetchCollectionInfoFromMoralis(contractAddress, chainId); + metadata = { ...metadata, ...moralisData }; + } catch (error) { + console.warn("Moralis metadata fetch failed:", error); + apiStatus.moralis = false; + apiStatus.lastChecked = Date.now(); + } + } + + if (apiStatus.etherscan && (!metadata.description || !metadata.imageUrl)) { + try { + const etherscanData = await fetchCollectionInfoFromEtherscan(contractAddress, chainId); + metadata = { ...metadata, ...etherscanData }; + } catch (error) { + console.warn("Etherscan metadata fetch failed:", error); + apiStatus.etherscan = false; + apiStatus.lastChecked = Date.now(); + } + } + } + + // Try marketplace data lookup for floor price, etc. + const marketData = await fetchMarketplaceData(contractAddress, chainId); + metadata.floorPrice = marketData?.floorPrice || '0'; + metadata.volume24h = marketData?.volume24h || '0'; + + // Every 5 minutes, reset API status to retry failed providers + if (Date.now() - apiStatus.lastChecked > 5 * 60 * 1000) { + apiStatus.alchemy = true; + apiStatus.moralis = true; + apiStatus.etherscan = true; + apiStatus.bscscan = true; + apiStatus.lastChecked = Date.now(); + } + // Save to cache - collectionsCache.set(cacheKey, metadata); + const fullMetadata = metadata as CollectionMetadata; + collectionsCache.set(cacheKey, fullMetadata); - return metadata; + return fullMetadata; } catch (error) { console.error('Error fetching collection information:', error); toast.error("Failed to load collection info"); @@ -159,6 +239,189 @@ export async function fetchCollectionInfo(contractAddress: string, chainId: stri } } +/** + * Fetch collection info from Alchemy + */ +async function fetchCollectionInfoFromAlchemy(contractAddress: string, chainId: string): Promise> { + const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; + const apiUrl = `https://${network}.g.alchemy.com/nft/v2/${ALCHEMY_API_KEY}/getContractMetadata`; + const url = new URL(apiUrl); + url.searchParams.append('contractAddress', contractAddress); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Alchemy API error: ${response.status}`); + } + + const data = await response.json(); + + return { + description: data?.contractMetadata?.openSea?.description || '', + imageUrl: data?.contractMetadata?.openSea?.imageUrl || '', + bannerImageUrl: data?.contractMetadata?.openSea?.bannerImageUrl || '', + verified: data?.contractMetadata?.openSea?.safelistRequestStatus === 'verified', + category: data?.contractMetadata?.openSea?.category || 'Art', + creatorAddress: data?.contractMetadata?.openSea?.creator || '', + website: data?.contractMetadata?.openSea?.externalUrl || '', + discord: data?.contractMetadata?.openSea?.discordUrl || '', + twitter: data?.contractMetadata?.openSea?.twitterUsername + ? `https://twitter.com/${data.contractMetadata.openSea.twitterUsername}` + : '' + }; +} + +/** + * Fetch collection info from Moralis + */ +async function fetchCollectionInfoFromMoralis(contractAddress: string, chainId: string): Promise> { + if (!MORALIS_API_KEY) { + throw new Error('Moralis API key not available'); + } + + // Convert chainId to Moralis format + const moralisChain = isBNBChain(chainId) + ? (chainId === '0x38' ? 'bsc' : 'bsc testnet') + : (chainId === '0x1' ? 'eth' : chainId === '0xaa36a7' ? 'sepolia' : 'goerli'); + + const options = { + method: 'GET', + url: `https://deep-index.moralis.io/api/v2/nft/${contractAddress}/metadata`, + params: {chain: moralisChain}, + headers: { + accept: 'application/json', + 'X-API-Key': MORALIS_API_KEY + } + }; + + const response = await axios.request(options); + + if (response.status !== 200) { + throw new Error(`Moralis API error: ${response.status}`); + } + + const data = response.data; + + return { + name: data?.name || '', + symbol: data?.symbol || '', + totalSupply: data?.synced_at ? data.total_supply?.toString() || '0' : '0', + description: data?.description || '', + imageUrl: data?.token_uri_metadata?.image || data?.metadata?.image || '', + category: data?.token_uri_metadata?.category || 'Art' + }; +} + +/** + * Fetch collection info from Etherscan + */ +async function fetchCollectionInfoFromEtherscan(contractAddress: string, chainId: string): Promise> { + if (!ETHERSCAN_API_KEY) { + throw new Error('Etherscan API key not available'); + } + + // Only applicable for Ethereum chains + if (!isEthereumChain(chainId)) { + throw new Error('Etherscan only supports Ethereum chains'); + } + + // Get appropriate Etherscan domain + let domain = 'api.etherscan.io'; + if (chainId === '0xaa36a7') { + domain = 'api-sepolia.etherscan.io'; + } else if (chainId === '0x5') { + domain = 'api-goerli.etherscan.io'; + } + + // Fetch contract ABI to check if it's verified + const abiUrl = `https://${domain}/api?module=contract&action=getabi&address=${contractAddress}&apikey=${ETHERSCAN_API_KEY}`; + const abiResponse = await fetch(abiUrl); + if (!abiResponse.ok) { + throw new Error(`Etherscan API error: ${abiResponse.status}`); + } + + const abiData = await abiResponse.json(); + const isVerified = abiData.status === '1' && abiData.message === 'OK'; + + // Get contract source code which may contain metadata + const sourceUrl = `https://${domain}/api?module=contract&action=getsourcecode&address=${contractAddress}&apikey=${ETHERSCAN_API_KEY}`; + const sourceResponse = await fetch(sourceUrl); + if (!sourceResponse.ok) { + throw new Error(`Etherscan API error: ${sourceResponse.status}`); + } + + const sourceData = await sourceResponse.json(); + + const result: Partial = { verified: isVerified }; + + if (sourceData.status === '1' && sourceData.result && sourceData.result.length > 0) { + const contractSource = sourceData.result[0]; + + // Try to extract metadata from contract source + try { + if (contractSource.Implementation) { + result.name = contractSource.ContractName || ''; + } + } catch (e) { + console.warn('Error parsing Etherscan metadata:', e); + } + } + + return result; +} + +/** + * Fetch collection info from BSCScan + */ +async function fetchCollectionInfoFromBSCScan(contractAddress: string, chainId: string): Promise> { + if (!BSCSCAN_API_KEY) { + throw new Error('BSCScan API key not available'); + } + + // Only applicable for BNB chains + if (!isBNBChain(chainId)) { + throw new Error('BSCScan only supports BNB Chain'); + } + + // Get appropriate BSCScan domain + const domain = chainId === '0x38' ? 'api.bscscan.com' : 'api-testnet.bscscan.com'; + + // Fetch contract ABI to check if it's verified + const abiUrl = `https://${domain}/api?module=contract&action=getabi&address=${contractAddress}&apikey=${BSCSCAN_API_KEY}`; + const abiResponse = await fetch(abiUrl); + if (!abiResponse.ok) { + throw new Error(`BSCScan API error: ${abiResponse.status}`); + } + + const abiData = await abiResponse.json(); + const isVerified = abiData.status === '1' && abiData.message === 'OK'; + + // Get contract source code which may contain metadata + const sourceUrl = `https://${domain}/api?module=contract&action=getsourcecode&address=${contractAddress}&apikey=${BSCSCAN_API_KEY}`; + const sourceResponse = await fetch(sourceUrl); + if (!sourceResponse.ok) { + throw new Error(`BSCScan API error: ${sourceResponse.status}`); + } + + const sourceData = await sourceResponse.json(); + + const result: Partial = { verified: isVerified }; + + if (sourceData.status === '1' && sourceData.result && sourceData.result.length > 0) { + const contractSource = sourceData.result[0]; + + // Try to extract metadata from contract source + try { + if (contractSource.Implementation) { + result.name = contractSource.ContractName || ''; + } + } catch (e) { + console.warn('Error parsing BSCScan metadata:', e); + } + } + + return result; +} + /** * Fetch marketplace data (floor price, volume, etc.) */ @@ -224,7 +487,9 @@ export async function fetchCollectionNFTs( sortBy?: string, sortDirection?: 'asc' | 'desc', searchQuery?: string, - attributes?: Record + attributes?: Record, + pageKey?: string, + startTokenId?: number } = {} ): Promise<{ nfts: NFTMetadata[], @@ -237,24 +502,106 @@ export async function fetchCollectionNFTs( sortBy = 'tokenId', sortDirection = 'asc', searchQuery = '', - attributes = {} + attributes = {}, + pageKey, + startTokenId } = options; - // Check if we should use direct contract fetching or API - // For well-known collections or testnet, use direct contract fetching + // Special handling for startTokenId-based pagination + if (startTokenId !== undefined && sortBy === 'tokenId') { + try { + console.log(`Using token ID-based pagination starting from ID: ${startTokenId}`); + + // For known collections that can be fetched directly + const useDirectFetching = [ + '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551', // CryptoPath Genesis on BNB Testnet + '0x7c09282c24c363073e0f30d74c301c312e5533ac' + ].includes(contractAddress.toLowerCase()); + + let nfts: NFTMetadata[] = []; + + if (useDirectFetching) { + // Try direct contract fetching first for known collections + try { + nfts = await fetchContractNFTs(contractAddress, chainId, startTokenId, pageSize); + } catch (error) { + console.error("Direct contract fetching failed:", error); + // Fall through to API methods + } + } + + // If direct fetching didn't return results, try API methods + if (nfts.length === 0) { + // For APIs that support offset/paging with start IDs + // Fixed: Changed fetchCollectionByAPI to fetchCollectionNFTs + const apiResult = await fetchCollectionNFTs( + contractAddress, + chainId, + { + page, + pageSize, + sortBy, + sortDirection, + searchQuery, + attributes + } + ); + + // Filter results to only include NFTs with token IDs >= startTokenId + // Fixed: Added type annotation for nft parameter + if (apiResult.nfts.length > 0) { + nfts = apiResult.nfts.filter((nft: NFTMetadata) => { + const tokenId = parseInt(nft.tokenId); + return !isNaN(tokenId) && tokenId >= startTokenId; + }).slice(0, pageSize); // Limit to pageSize + } + } + + // Calculate what the next cursor should be + let nextTokenId: number | undefined; + if (nfts.length > 0) { + const lastNft = nfts[nfts.length - 1]; + const lastTokenId = parseInt(lastNft.tokenId); + if (!isNaN(lastTokenId)) { + nextTokenId = lastTokenId + 1; + } + } + + // Get total count either from collection info or estimate + let totalCount = nfts.length > 0 ? Math.max(nfts.length + startTokenId, pageSize * 5) : 0; + try { + const info = await fetchCollectionInfo(contractAddress, chainId); + if (info && info.totalSupply && parseInt(info.totalSupply) > 0) { + totalCount = parseInt(info.totalSupply); + } + } catch (e) { + console.warn('Could not fetch total supply from collection info'); + } + + return { + nfts, + totalCount, + pageKey: nfts.length > 0 && nextTokenId ? `synthetic:tokenId:${nextTokenId}:${sortDirection}` : undefined + }; + } catch (error) { + console.error("Failed token ID based pagination:", error); + // Fall through to regular pagination + } + } + + // Check if we should use direct contract fetching for known collections const useDirectFetching = [ - // Our CryptoPath Genesis on BNB Testnet - '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551', - // Some popular testnet or demo collections + '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551', // CryptoPath Genesis on BNB Testnet '0x7c09282c24c363073e0f30d74c301c312e5533ac' ].includes(contractAddress.toLowerCase()); try { let nfts: NFTMetadata[] = []; let totalCount = 0; + let resultPageKey: string | undefined = undefined; if (useDirectFetching) { - // Check cache first + // Simply proceed with direct fetching if this is a known collection const cacheKey = `${chainId}-${contractAddress.toLowerCase()}-nfts`; const cachedData = collectionNFTsCache.get(cacheKey); @@ -274,35 +621,157 @@ export async function fetchCollectionNFTs( totalCount = nfts.length > 0 ? parseInt(await fetchCollectionInfo(contractAddress, chainId).then(info => info.totalSupply)) : 0; } else { - // Use Alchemy API for production collections - const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; - const apiUrl = `https://${network}.g.alchemy.com/nft/v2/${ALCHEMY_API_KEY}/getNFTsForCollection`; - const url = new URL(apiUrl); - url.searchParams.append('contractAddress', contractAddress); - url.searchParams.append('withMetadata', 'true'); - url.searchParams.append('startToken', ((page - 1) * pageSize).toString()); - url.searchParams.append('limit', pageSize.toString()); - - const response = await fetch(url.toString()); - - if (!response.ok) { - throw new Error(`API request failed with status ${response.status}`); + // Define API strategies based on chain + if (isBNBChain(chainId)) { + // BNB Chain: Try Moralis first (priority), then BSCScan, finally contract fallback + let success = false; + + // Try Moralis first for BNB Chain (preferred) + if (MORALIS_API_KEY) { + try { + console.log('Trying Moralis for BNB Chain'); + const result = await fetchNFTsFromMoralis(contractAddress, chainId, page, pageSize); + nfts = result.nfts; + totalCount = result.totalCount; + success = true; + console.log('Successfully fetched from Moralis'); + } catch (error) { + console.warn("Moralis NFT fetch failed:", error); + apiStatus.moralis = false; + apiStatus.lastChecked = Date.now(); + } + } else { + console.log('Skipping Moralis - No API key available'); + } + + // Try BSCScan as fallback for BNB Chain + if (!success && BSCSCAN_API_KEY) { + try { + console.log('Trying BSCScan for BNB Chain'); + const result = await fetchNFTsFromBSCScan(contractAddress, chainId, page, pageSize); + nfts = result.nfts; + totalCount = result.totalCount; + success = true; + console.log('Successfully fetched from BSCScan'); + } catch (error) { + console.warn("BSCScan NFT fetch failed:", error); + apiStatus.bscscan = false; + apiStatus.lastChecked = Date.now(); + } + } else if (!success) { + console.log('Skipping BSCScan - No API key available'); + } + + // Last resort for BNB Chain: direct contract fetching + if (!success) { + console.log('Falling back to direct contract fetching for BNB Chain'); + const startIndex = (page - 1) * pageSize; + try { + nfts = await fetchContractNFTs(contractAddress, chainId, startIndex, pageSize); + // Generate a mock total count if contract fetching worked but we don't know the total + totalCount = nfts.length > 0 ? Math.max(nfts.length, pageSize * 2) : 0; + + // If we have collection info, use that for total supply + try { + const info = await fetchCollectionInfo(contractAddress, chainId); + if (info && info.totalSupply) { + totalCount = parseInt(info.totalSupply); + } + } catch (e) { + console.warn('Could not fetch total supply from collection info'); + } + } catch (contractError) { + console.error('Contract fetching failed:', contractError); + // Return empty list as last resort + nfts = []; + totalCount = 0; + } + } + } else { + // Ethereum: Try Alchemy (priority) -> Moralis -> Etherscan -> Contract fallback + let success = false; + + // Try Alchemy first for Ethereum (preferred) + if (ALCHEMY_API_KEY) { + try { + console.log('Trying Alchemy for Ethereum'); + const result = await fetchNFTsFromAlchemy(contractAddress, chainId, page, pageSize); + nfts = result.nfts; + totalCount = result.totalCount; + resultPageKey = result.pageKey; + success = true; + console.log('Successfully fetched from Alchemy'); + } catch (error) { + console.warn("Alchemy NFT fetch failed:", error); + apiStatus.alchemy = false; + apiStatus.lastChecked = Date.now(); + } + } else { + console.log('Skipping Alchemy - No API key available'); + } + + // Try Moralis as second option for Ethereum + if (!success && MORALIS_API_KEY) { + try { + console.log('Trying Moralis for Ethereum'); + const result = await fetchNFTsFromMoralis(contractAddress, chainId, page, pageSize); + nfts = result.nfts; + totalCount = result.totalCount; + success = true; + console.log('Successfully fetched from Moralis'); + } catch (error) { + console.warn("Moralis NFT fetch failed:", error); + apiStatus.moralis = false; + apiStatus.lastChecked = Date.now(); + } + } else if (!success) { + console.log('Skipping Moralis - No API key available'); + } + + // Try Etherscan as third option for Ethereum + if (!success && ETHERSCAN_API_KEY) { + try { + console.log('Trying Etherscan for Ethereum'); + const result = await fetchNFTsFromEtherscan(contractAddress, chainId, page, pageSize); + nfts = result.nfts; + totalCount = result.totalCount; + success = true; + console.log('Successfully fetched from Etherscan'); + } catch (error) { + console.warn("Etherscan NFT fetch failed:", error); + apiStatus.etherscan = false; + apiStatus.lastChecked = Date.now(); + } + } else if (!success) { + console.log('Skipping Etherscan - No API key available'); + } + + // Last resort for Ethereum: direct contract fetching + if (!success) { + console.log('Falling back to direct contract fetching for Ethereum'); + const startIndex = (page - 1) * pageSize; + try { + nfts = await fetchContractNFTs(contractAddress, chainId, startIndex, pageSize); + // Generate a mock total count if contract fetching worked but we don't know the total + totalCount = nfts.length > 0 ? Math.max(nfts.length, pageSize * 2) : 0; + + // If we have collection info, use that for total supply + try { + const info = await fetchCollectionInfo(contractAddress, chainId); + if (info && info.totalSupply) { + totalCount = parseInt(info.totalSupply); + } + } catch (e) { + console.warn('Could not fetch total supply from collection info'); + } + } catch (contractError) { + console.error('Contract fetching failed:', contractError); + // Return empty list as last resort + nfts = []; + totalCount = 0; + } + } } - - const data = await response.json(); - - // Map Alchemy data to our format - nfts = data.nfts.map((nft: any) => ({ - id: `${contractAddress.toLowerCase()}-${nft.id.tokenId || ''}`, - tokenId: nft.id.tokenId || '', - name: nft.title || `NFT #${parseInt(nft.id.tokenId || '0', 16).toString()}`, - description: nft.description || '', - imageUrl: nft.media?.[0]?.gateway || '', - attributes: nft.metadata?.attributes || [], - chain: chainId - })); - - totalCount = data.totalCount || nfts.length; } // Apply search filtering @@ -318,23 +787,30 @@ export async function fetchCollectionNFTs( // Apply attribute filtering if (Object.keys(attributes).length > 0) { nfts = nfts.filter(nft => { - for (const [traitType, values] of Object.entries(attributes)) { - if (values.length === 0) continue; + if (!nft.attributes) return false; + + // Check if NFT matches all selected attribute filters + return Object.entries(attributes).every(([traitType, values]) => { + if (values.length === 0) return true; // Skip this attribute if no values selected + + const nftAttribute = nft.attributes?.find(attr => + attr.trait_type.toLowerCase() === traitType.toLowerCase() + ); - const nftAttribute = nft.attributes?.find(attr => attr.trait_type === traitType); if (!nftAttribute || !values.includes(nftAttribute.value)) { return false; } - } - return true; + return true; + }); }); } // Apply sorting nfts.sort((a, b) => { if (sortBy === 'tokenId') { - const idA = parseInt(a.tokenId, 16) || 0; - const idB = parseInt(b.tokenId, 16) || 0; + // Handle numeric tokenIds properly + const idA = parseInt(a.tokenId, 10) || 0; + const idB = parseInt(b.tokenId, 10) || 0; return sortDirection === 'asc' ? idA - idB : idB - idA; } else if (sortBy === 'name') { return sortDirection === 'asc' @@ -345,10 +821,19 @@ export async function fetchCollectionNFTs( return 0; }); + // Reset API status every 5 minutes to retry failed providers + if (Date.now() - apiStatus.lastChecked > 5 * 60 * 1000) { + apiStatus.alchemy = true; + apiStatus.moralis = true; + apiStatus.etherscan = true; + apiStatus.bscscan = true; + apiStatus.lastChecked = Date.now(); + } + return { nfts, totalCount, - pageKey: undefined // Alchemy might return a pageKey for pagination + pageKey: resultPageKey }; } catch (error) { console.error(`Error fetching NFTs for collection ${contractAddress}:`, error); @@ -358,1128 +843,1135 @@ export async function fetchCollectionNFTs( } /** - * Fetch user-owned NFTs across all collections + * Fetch NFTs from Alchemy API */ -export async function fetchUserNFTs(address: string, chainId: string, pageKey?: string): Promise<{ - ownedNfts: any[], +async function fetchNFTsFromAlchemy( + contractAddress: string, + chainId: string, + page: number, + pageSize: number +): Promise<{ + nfts: NFTMetadata[], totalCount: number, pageKey?: string }> { - if (!address) { - throw new Error("Address is required to fetch NFTs"); - } - - const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; + // Use our existing Alchemy API integration + const result = await alchemyFetchCollectionNFTs( + contractAddress, + chainId, + page, + pageSize, + 'tokenId', + 'asc' + ); - try { - const apiUrl = `https://${network}.g.alchemy.com/nft/v2/${ALCHEMY_API_KEY}/getNFTs`; - const url = new URL(apiUrl); - url.searchParams.append('owner', address); - url.searchParams.append('withMetadata', 'true'); - url.searchParams.append('excludeFilters[]', 'SPAM'); - url.searchParams.append('pageSize', '100'); - - if (pageKey) { - url.searchParams.append('pageKey', pageKey); - } - - const response = await fetch(url.toString()); - - if (!response.ok) { - throw new Error(`API request failed with status ${response.status}`); - } - - return await response.json(); - } catch (error) { - console.error(`Error fetching NFTs for ${address}:`, error); - toast.error("Failed to load NFTs"); - return { ownedNfts: [], totalCount: 0 }; - } + // Map to our NFTMetadata format + const mappedNfts: NFTMetadata[] = result.nfts.map(nft => ({ + id: `${contractAddress.toLowerCase()}-${nft.tokenId}`, + tokenId: nft.tokenId, + name: nft.name || `NFT #${nft.tokenId}`, + description: nft.description || '', + imageUrl: nft.imageUrl || '', + attributes: nft.attributes || [], + chain: chainId + })); + + return { + nfts: mappedNfts, + totalCount: result.totalCount, + pageKey: result.pageKey + }; } /** - * Fetch popular NFT collections for a specific chain + * Fetch NFTs from Moralis API with better error handling */ -export async function fetchPopularCollections(chainId: string): Promise { +async function fetchNFTsFromMoralis( + contractAddress: string, + chainId: string, + page: number, + pageSize: number +): Promise<{ + nfts: NFTMetadata[], + totalCount: number +}> { + if (!MORALIS_API_KEY) { + throw new Error('Moralis API key not available'); + } + + // Convert chainId to Moralis format + const moralisChain = isBNBChain(chainId) + ? (chainId === '0x38' ? 'bsc' : 'bsc testnet') + : (chainId === '0x1' ? 'eth' : chainId === '0xaa36a7' ? 'sepolia' : 'goerli'); + try { - const cacheKey = `popular-collections-${chainId}`; - - // Check cache first - if (collectionsCache.has(cacheKey)) { - return collectionsCache.get(cacheKey); - } + const options = { + method: 'GET', + url: `https://deep-index.moralis.io/api/v2/nft/${contractAddress}`, + params: { + chain: moralisChain, + format: 'decimal', + limit: pageSize, + cursor: '', // Moralis uses cursor-based pagination + offset: (page - 1) * pageSize + }, + headers: { + accept: 'application/json', + 'X-API-Key': MORALIS_API_KEY + } + }; - // Get list of popular collection addresses for this chain - const popularAddresses = POPULAR_NFT_COLLECTIONS[chainId as keyof typeof POPULAR_NFT_COLLECTIONS] || []; + const response = await axios.request(options); - if (popularAddresses.length === 0) { - return []; + if (response.status !== 200) { + throw new Error(`Moralis API error: ${response.status}`); } - // Fetch detailed info for each collection - const collectionPromises = popularAddresses.map(async (collection) => { - const collectionInfo = await fetchCollectionInfo(collection.address, chainId); + const data = response.data; + + // Map Moralis data to our format + const nfts: NFTMetadata[] = data.result.map((item: any) => { + // Try to parse metadata + let attributes: {trait_type: string, value: string}[] = []; + let name = `NFT #${item.token_id}`; + let description = ''; + let imageUrl = ''; + + try { + if (item.metadata) { + const metadata = typeof item.metadata === 'string' + ? JSON.parse(item.metadata) + : item.metadata; + + name = metadata.name || name; + description = metadata.description || ''; + imageUrl = metadata.image || ''; + + if (metadata.attributes && Array.isArray(metadata.attributes)) { + attributes = metadata.attributes.map((attr: any) => ({ + trait_type: attr.trait_type || '', + value: attr.value || '' + })); + } + } + } catch (e) { + console.warn('Error parsing Moralis NFT metadata:', e); + } - // Add extra details that might be missing from the base fetch return { - ...collectionInfo, - name: collection.name || collectionInfo.name, - description: collection.description || collectionInfo.description, - // For our special CryptoPath collection on BNB Testnet - ...(collection.address.toLowerCase() === '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551' && chainId === '0x61' ? { - featured: true, - imageUrl: '/Img/logo/cryptopath.png', - bannerImageUrl: '/Img/logo/logo4.svg' - } : {}) + id: `${contractAddress.toLowerCase()}-${item.token_id}`, + tokenId: item.token_id, + name, + description, + imageUrl, + attributes, + chain: chainId }; }); - const collections = await Promise.all(collectionPromises); - - // Cache the results - collectionsCache.set(cacheKey, collections); - - return collections; + return { + nfts, + totalCount: data.total || nfts.length + }; } catch (error) { - console.error('Error fetching popular collections:', error); - toast.error("Failed to load popular collections"); - return []; + console.error('Error in fetchNFTsFromMoralis:', error); + throw error; } } /** - * Fetch marketplace trading history for an NFT + * Fetch NFTs from Etherscan API */ -export async function fetchTradeHistory( - contractAddress: string, - tokenId?: string, - chainId: string = '0x1' -): Promise { - // This would normally connect to a blockchain indexer service - // For now, return mock data - - // Generate realistic mock data based on contract and token - const now = Date.now(); - const history = []; - const events = ['Sale', 'Transfer', 'Mint', 'List']; - const priceBase = tokenId ? - (parseInt(tokenId, 16) % 100) / 10 + 0.5 : // Use tokenId to generate a base price - 5 + Math.random() * 15; // Random base price for collection - - // Special handling for known collections to make data more realistic - let isSpecialCollection = false; - - if (chainId === '0x1') { - if (contractAddress.toLowerCase() === '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d') { - // BAYC - isSpecialCollection = true; - const bayc_events = [ - { - id: '1', - event: 'Sale', - price: (75 + Math.random() * 20).toFixed(2), - timestamp: new Date(now - 1000 * 60 * 60 * 24 * 2).toISOString(), - }, - { - id: '2', - event: 'Sale', - price: (60 + Math.random() * 15).toFixed(2), - timestamp: new Date(now - 1000 * 60 * 60 * 24 * 30).toISOString(), - }, - { - id: '3', - event: 'Mint', - timestamp: new Date(now - 1000 * 60 * 60 * 24 * 365).toISOString(), - } - ]; +async function fetchNFTsFromEtherscan( + contractAddress: string, + chainId: string, + page: number, + pageSize: number +): Promise<{ + nfts: NFTMetadata[], + totalCount: number +}> { + if (!ETHERSCAN_API_KEY) { + throw new Error('Etherscan API key not available'); + } + + if (!isEthereumChain(chainId)) { + throw new Error('Etherscan only supports Ethereum chains'); + } + + // Get appropriate Etherscan domain + let domain = 'api.etherscan.io'; + if (chainId === '0xaa36a7') { + domain = 'api-sepolia.etherscan.io'; + } else if (chainId === '0x5') { + domain = 'api-goerli.etherscan.io'; + } + + // Get token info from ABI + const apiUrl = `https://${domain}/api?module=account&action=tokennfttx&contractaddress=${contractAddress}&page=${page}&offset=${pageSize}&sort=asc&apikey=${ETHERSCAN_API_KEY}`; + + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`Etherscan API error: ${response.status}`); + } + + const data = await response.json(); + + if (data.status !== '1') { + throw new Error(`Etherscan API error: ${data.message}`); + } + + // Need to deduplicate token IDs as transfer events might have duplicates + const tokenSet = new Set(); + const transferEvents = data.result || []; + + transferEvents.forEach((tx: any) => { + tokenSet.add(tx.tokenID); + }); + + const tokenIds = Array.from(tokenSet).slice(0, pageSize); + + // For each token ID, try to get metadata from the NFT contract + const nftPromises = tokenIds.map(async (tokenId) => { + try { + // Try direct contract query for token URI and metadata + const provider = getChainProvider(chainId); + const abi = ["function tokenURI(uint256 tokenId) view returns (string)"]; + const contract = new ethers.Contract(contractAddress, abi, provider); - for (const evt of bayc_events) { - history.push({ - ...evt, - tokenId: tokenId || Math.floor(Math.random() * 10000).toString(), - from: evt.event === 'Mint' ? '0x0000000000000000000000000000000000000000' : `0x${Math.random().toString(16).slice(2, 42)}`, - to: `0x${Math.random().toString(16).slice(2, 42)}`, - txHash: `0x${Math.random().toString(16).slice(2, 66)}` - }); - } - } - } else if (chainId === '0x61' && contractAddress.toLowerCase() === '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551') { - // CryptoPath Genesis on BNB Testnet - isSpecialCollection = true; - const cryptopath_events = [ - { - id: '1', - event: 'Sale', - price: (12.5 + Math.random() * 5).toFixed(2), - timestamp: new Date(now - 1000 * 60 * 60 * 12).toISOString(), - }, - { - id: '2', - event: 'List', - price: (10 + Math.random() * 5).toFixed(2), - timestamp: new Date(now - 1000 * 60 * 60 * 24 * 2).toISOString(), - }, - { - id: '3', - event: 'Transfer', - timestamp: new Date(now - 1000 * 60 * 60 * 24 * 5).toISOString(), - }, - { - id: '4', - event: 'Mint', - timestamp: new Date(now - 1000 * 60 * 60 * 24 * 10).toISOString(), + // Get token URI + const tokenUri = await contract.tokenURI(tokenId).catch(() => ''); + + // Fetch metadata if token URI is available + let metadata: any = {}; + if (tokenUri) { + try { + // Handle IPFS URIs + const metadataUrl = tokenUri.replace('ipfs://', 'https://ipfs.io/ipfs/'); + const metadataResponse = await fetch(metadataUrl); + if (metadataResponse.ok) { + metadata = await metadataResponse.json(); + } + } catch (e) { + console.warn(`Error fetching metadata for token ${tokenId}:`, e); + } } - ]; - - for (const evt of cryptopath_events) { - history.push({ - ...evt, - tokenId: tokenId || Math.floor(Math.random() * 1000).toString(), - from: evt.event === 'Mint' ? '0x0000000000000000000000000000000000000000' : `0x${Math.random().toString(16).slice(2, 42)}`, - to: evt.event === 'List' ? '0x0000000000000000000000000000000000000000' : `0x${Math.random().toString(16).slice(2, 42)}`, - txHash: `0x${Math.random().toString(16).slice(2, 66)}` - }); + + // Create NFTMetadata object + return { + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId, + name: metadata.name || `NFT #${tokenId}`, + description: metadata.description || '', + imageUrl: metadata.image || '', + attributes: metadata.attributes || [], + chain: chainId + }; + } catch (e) { + console.warn(`Error getting NFT data for token ${tokenId}:`, e); + // Return placeholder for errors + return { + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId, + name: `NFT #${tokenId}`, + description: '', + imageUrl: '', + attributes: [], + chain: chainId + }; } - } + }); - // Generate generic events if no special collection was matched - if (!isSpecialCollection) { - // Create 3-6 random events - const numEvents = 3 + Math.floor(Math.random() * 4); - - for (let i = 0; i < numEvents; i++) { - const event = events[Math.floor(Math.random() * events.length)]; - const daysAgo = Math.floor(Math.random() * 180); // Random event up to 6 months ago - const priceMultiplier = 0.8 + Math.random() * 0.4; // Random price variation - - history.push({ - id: i.toString(), - event, - tokenId: tokenId || Math.floor(Math.random() * 10000).toString(), - from: event === 'Mint' ? '0x0000000000000000000000000000000000000000' : `0x${Math.random().toString(16).slice(2, 42)}`, - to: event === 'List' ? '0x0000000000000000000000000000000000000000' : `0x${Math.random().toString(16).slice(2, 42)}`, - price: event === 'Sale' || event === 'List' ? (priceBase * priceMultiplier).toFixed(2) : undefined, - timestamp: new Date(now - 1000 * 60 * 60 * 24 * daysAgo - 1000 * 60 * 60 * Math.random() * 24).toISOString(), - txHash: `0x${Math.random().toString(16).slice(2, 66)}` - }); + const nfts = await Promise.all(nftPromises); + + // Get total count - this is a rough estimate based on transfer events + const apiUrlForCount = `https://${domain}/api?module=stats&action=tokensupply&contractaddress=${contractAddress}&apikey=${ETHERSCAN_API_KEY}`; + + let totalCount = tokenSet.size; + try { + const countResponse = await fetch(apiUrlForCount); + if (countResponse.ok) { + const countData = await countResponse.json(); + if (countData.status === '1') { + totalCount = parseInt(countData.result, 10); + } } + } catch (e) { + console.warn('Error getting total token count:', e); } - // Sort by timestamp - return history.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + return { + nfts, + totalCount + }; } /** - * Fetch price history data for charts + * Fetch NFTs from BSCScan API with better error handling */ -export async function fetchPriceHistory( - contractAddress: string, - tokenId?: string, - chainId: string = '0x1' -): Promise { - // Generate realistic price history data based on real market trends - const now = Date.now(); - const data = []; - const days = 90; // 3 months of data - - // Determine base price and volatility based on collection - let basePrice = 1; - let volatility = 0.05; - let trend = 0; // Neutral trend by default - - // Special handling for known collections - if (chainId === '0x1') { - if (contractAddress.toLowerCase() === '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d') { - // BAYC - high value, high volatility - basePrice = 70; - volatility = 0.08; - trend = 0.001; // Slight uptrend - } else if (contractAddress.toLowerCase() === '0xed5af388653567af2f388e6224dc7c4b3241c544') { - // Azuki - basePrice = 8; - volatility = 0.06; - trend = 0.0005; - } else if (contractAddress.toLowerCase() === '0x60e4d786628fea6478f785a6d7e704777c86a7c6') { - // MAYC - basePrice = 10; - volatility = 0.07; - trend = 0.0007; - } - } else if (chainId === '0x38') { - if (contractAddress.toLowerCase() === '0x0a8901b0e25deb55a87524f0cc164e9644020eba') { - // Pancake Squad - basePrice = 2; - volatility = 0.04; - trend = 0.0008; // Stronger uptrend - } - } else if (chainId === '0x61' && contractAddress.toLowerCase() === '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551') { - // CryptoPath Genesis - basePrice = 10; - volatility = 0.06; - trend = 0.002; // Strong growth - } else { - // Use token ID to influence base price if available - basePrice = tokenId ? - (parseInt(tokenId, 16) % 100) / 10 + 0.5 : // Use tokenId to generate a base price - 1 + Math.random() * 5; // Random base price for collection +async function fetchNFTsFromBSCScan( + contractAddress: string, + chainId: string, + page: number, + pageSize: number +): Promise<{ + nfts: NFTMetadata[], + totalCount: number +}> { + if (!BSCSCAN_API_KEY) { + throw new Error('BSCScan API key not available'); } - // Generate prices with realistic market movements - let price = basePrice; - for (let i = days; i >= 0; i--) { - const date = new Date(now - 1000 * 60 * 60 * 24 * i); - - // Apply market factors - const dayOfWeek = date.getDay(); - const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; - const weekendFactor = isWeekend ? (Math.random() > 0.5 ? 0.01 : -0.01) : 0; // Weekend volatility - - // Market cycle - simulate some cyclical behavior (10-day cycles) - const cycleFactor = 0.02 * Math.sin(i / 10); - - // Apply trend (accumulated over time) + random volatility + cyclical factor + weekend effect - price = price * (1 + trend + (Math.random() - 0.5) * volatility + cycleFactor + weekendFactor); - - // Floor at 10% of base price to avoid unrealistic crashes - price = Math.max(price, basePrice * 0.1); - - data.push({ - date: date.toISOString().split('T')[0], - price: price.toFixed(4) - }); + if (!isBNBChain(chainId)) { + throw new Error('BSCScan only supports BNB Chain'); } - return data; -} - -/** - * Get all traits and their values for a collection - */ -export async function fetchCollectionTraits(contractAddress: string, chainId: string): Promise> { + // Get appropriate BSCScan domain + const domain = chainId === '0x38' ? 'api.bscscan.com' : 'api-testnet.bscscan.com'; + + // Get token info from BSCScan + const apiUrl = `https://${domain}/api?module=account&action=tokennfttx&contractaddress=${contractAddress}&page=${page}&offset=${pageSize}&sort=asc&apikey=${BSCSCAN_API_KEY}`; + try { - // Try to get traits from Alchemy API - const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; - const apiUrl = `https://${network}.g.alchemy.com/nft/v2/${ALCHEMY_API_KEY}/getContractMetadata`; - const url = new URL(apiUrl); - url.searchParams.append('contractAddress', contractAddress); + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`BSCScan API error: ${response.status}`); + } - const response = await fetch(url.toString()); + const data = await response.json(); - if (response.ok) { - const data = await response.json(); - - // Check if the data includes attribute data - if (data.contractMetadata?.openSea?.traits) { - const traits: Record = {}; - - // Parse OpenSea traits format - for (const [traitType, values] of Object.entries(data.contractMetadata.openSea.traits)) { - traits[traitType] = Array.isArray(values) ? values : Object.keys(values as object); - } - - return traits; + if (data.status !== '1') { + // Handle rate limits gracefully + if (data.message?.includes('rate limit')) { + console.warn('BSCScan rate limit reached'); + return { nfts: [], totalCount: 0 }; } + throw new Error(`BSCScan API error: ${data.message}`); } - // Fallback: Fetch a sample of NFTs and extract traits - const nfts = await fetchCollectionNFTs(contractAddress, chainId, { - pageSize: 100 // Fetch a larger sample to get more traits + // Need to deduplicate token IDs as transfer events might have duplicates + const tokenSet = new Set(); + const transferEvents = data.result || []; + + transferEvents.forEach((tx: any) => { + tokenSet.add(tx.tokenID); }); - const traits: Record = {}; + const tokenIds = Array.from(tokenSet).slice(0, pageSize); - // Extract unique traits and values - nfts.nfts.forEach(nft => { - if (nft.attributes) { - nft.attributes.forEach(attr => { - if (!traits[attr.trait_type]) { - traits[attr.trait_type] = []; - } - - if (!traits[attr.trait_type].includes(attr.value)) { - traits[attr.trait_type].push(attr.value); + // For each token ID, try to get metadata from the NFT contract + const nftPromises = tokenIds.map(async (tokenId) => { + try { + // Try direct contract query for token URI and metadata + const provider = getChainProvider(chainId); + const abi = ["function tokenURI(uint256 tokenId) view returns (string)"]; + const contract = new ethers.Contract(contractAddress, abi, provider); + + // Get token URI + let tokenUri = ''; + try { + tokenUri = await contract.tokenURI(tokenId).catch(() => ''); + } catch (e) { + console.warn(`Error getting tokenURI for token ${tokenId}:`, e); + } + + // Fetch metadata if token URI is available + let metadata: any = {}; + if (tokenUri) { + try { + // Handle IPFS URIs + const metadataUrl = tokenUri.replace('ipfs://', 'https://ipfs.io/ipfs/'); + const metadataResponse = await fetch(metadataUrl); + if (metadataResponse.ok) { + metadata = await metadataResponse.json(); + } + } catch (e) { + console.warn(`Error fetching metadata for token ${tokenId}:`, e); } - }); + } + + // Create NFTMetadata object + return { + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId, + name: metadata.name || `NFT #${tokenId}`, + description: metadata.description || '', + imageUrl: metadata.image || '', + attributes: metadata.attributes || [], + chain: chainId + }; + } catch (e) { + console.warn(`Error getting NFT data for token ${tokenId}:`, e); + // Return placeholder for errors + return { + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId, + name: `NFT #${tokenId}`, + description: '', + imageUrl: '', + attributes: [], + chain: chainId + }; } }); - // Sort values for each trait - for (const traitType in traits) { - traits[traitType].sort(); + const nfts = await Promise.all(nftPromises); + + // Get total count - this is a rough estimate based on transfer events + const apiUrlForCount = `https://${domain}/api?module=stats&action=tokensupply&contractaddress=${contractAddress}&apikey=${BSCSCAN_API_KEY}`; + + let totalCount = tokenSet.size; + try { + const countResponse = await fetch(apiUrlForCount); + if (countResponse.ok) { + const countData = await countResponse.json(); + if (countData.status === '1') { + totalCount = parseInt(countData.result, 10); + } + } + } catch (e) { + console.warn('Error getting total token count:', e); } - return traits; + return { + nfts, + totalCount + }; } catch (error) { - console.error('Error fetching collection traits:', error); - return {}; + console.error('Error in fetchNFTsFromBSCScan:', error); + throw error; } } /** - * Search for NFT collections across supported networks + * Fetch user-owned NFTs across all collections */ -export async function searchNFTCollections( - query: string, - chainIds: string[] = ['0x1', '0x38'] -): Promise { - try { - if (!query || query.length < 2) { - return []; +export async function fetchUserNFTs(address: string, chainId: string, pageKey?: string): Promise<{ + ownedNfts: any[], + totalCount: number, + pageKey?: string +}> { + if (!address) { + throw new Error("Address is required to fetch NFTs"); + } + + // Set API fallback order based on chain + if (isBNBChain(chainId)) { + // BNB Chain: Try Moralis -> BSCScan -> Contract fallback + if (apiStatus.moralis) { + try { + return await fetchUserNFTsFromMoralis(address, chainId); + } catch (error) { + console.warn("Moralis user NFTs fetch failed:", error); + apiStatus.moralis = false; + apiStatus.lastChecked = Date.now(); + } } - // Normalize query - const normalizedQuery = query.toLowerCase().trim(); - - // Search for collections on each chain - const searchPromises = chainIds.map(async (chainId) => { + if (apiStatus.bscscan) { try { - // Get popular collections for this chain - const collections = await fetchPopularCollections(chainId); + return await fetchUserNFTsFromBSCScan(address, chainId); + } catch (error) { + console.warn("BSCScan user NFTs fetch failed:", error); + apiStatus.bscscan = false; + apiStatus.lastChecked = Date.now(); + } + } + } else { + // Ethereum: Try Alchemy -> Moralis -> Etherscan -> Contract fallback + if (apiStatus.alchemy) { + try { + const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; - // Filter collections by search term - return collections.filter(collection => - collection.name.toLowerCase().includes(normalizedQuery) || - collection.description.toLowerCase().includes(normalizedQuery) || - collection.symbol.toLowerCase().includes(normalizedQuery) || - collection.contractAddress.toLowerCase() === normalizedQuery - ); + const apiUrl = `https://${network}.g.alchemy.com/nft/v2/${ALCHEMY_API_KEY}/getNFTs`; + const url = new URL(apiUrl); + url.searchParams.append('owner', address); + url.searchParams.append('withMetadata', 'true'); + url.searchParams.append('excludeFilters[]', 'SPAM'); + url.searchParams.append('pageSize', '100'); + + if (pageKey) { + url.searchParams.append('pageKey', pageKey); + } + + const response = await fetch(url.toString()); + + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}`); + } + + return await response.json(); } catch (error) { - console.error(`Error searching collections on chain ${chainId}:`, error); - return []; + console.warn("Alchemy user NFTs fetch failed:", error); + apiStatus.alchemy = false; + apiStatus.lastChecked = Date.now(); } - }); + } - const results = await Promise.all(searchPromises); + if (apiStatus.moralis) { + try { + return await fetchUserNFTsFromMoralis(address, chainId); + } catch (error) { + console.warn("Moralis user NFTs fetch failed:", error); + apiStatus.moralis = false; + apiStatus.lastChecked = Date.now(); + } + } - // Flatten results and sort by relevance - // For exact contract address matches, put them at the top - return results.flat().sort((a, b) => { - // Exact contract address match gets highest priority - if (a.contractAddress.toLowerCase() === normalizedQuery) return -1; - if (b.contractAddress.toLowerCase() === normalizedQuery) return 1; - - // Exact name match gets second priority - const aNameMatch = a.name.toLowerCase() === normalizedQuery; - const bNameMatch = b.name.toLowerCase() === normalizedQuery; - if (aNameMatch && !bNameMatch) return -1; - if (!aNameMatch && bNameMatch) return 1; - - // Otherwise, sort by name - return a.name.localeCompare(b.name); + if (apiStatus.etherscan) { + try { + return await fetchUserNFTsFromEtherscan(address, chainId); + } catch (error) { + console.warn("Etherscan user NFTs fetch failed:", error); + apiStatus.etherscan = false; + apiStatus.lastChecked = Date.now(); + } + } + } + + // Fallback to local mock if all APIs fail + console.warn("All APIs failed, using mock data"); + + // Generate some mock NFTs for the demo + const mockNFTs = []; + // Generate a deterministic but "random-looking" set of NFTs based on user address + const numNFTs = parseInt(address.slice(-2), 16) % 10 + 1; // 1-10 NFTs + + for (let i = 0; i < numNFTs; i++) { + mockNFTs.push({ + contract: { + address: `0x${address.slice(2, 10)}${i.toString(16).padStart(2, '0')}${address.slice(12, 42)}` + }, + id: { + tokenId: `${i + 1}` + }, + title: `Mock NFT #${i + 1}`, + description: "This is a mock NFT generated because APIs were unavailable", + tokenUri: { + gateway: "" + }, + media: [{ + gateway: `/Img/nft/sample-${(i % 5) + 1}.jpg` + }], + metadata: { + name: `Mock NFT #${i + 1}`, + attributes: [ + { trait_type: "Rarity", value: ["Common", "Uncommon", "Rare", "Epic", "Legendary"][i % 5] }, + { trait_type: "Type", value: ["Art", "Collectible", "Game", "Utility"][i % 4] } + ] + } }); - } catch (error) { - console.error('Error searching collections:', error); - toast.error("Failed to search collections"); - return []; } + + return { + ownedNfts: mockNFTs, + totalCount: mockNFTs.length + }; } /** - * Get statistics for a collection (floor price history, volume, etc.) + * Fetch user NFTs from Moralis */ -export async function getCollectionStats( - contractAddress: string, - chainId: string, - period: '1d' | '7d' | '30d' | 'all' = '7d' -): Promise<{ - floorPrice: number; - volume: number; - change: number; - sales: number; - averagePrice: number; - owners: number; - holders: { count: number, percentage: number }; - priceHistory: Array<{ date: string; price: number }>; +async function fetchUserNFTsFromMoralis(address: string, chainId: string): Promise<{ + ownedNfts: any[], + totalCount: number, + pageKey?: string }> { - try { - // Get price history for the chosen period - const priceData = await fetchPriceHistory(contractAddress, undefined, chainId); - - // Filter data based on period - const now = new Date(); - const pastDate = new Date(); - - switch (period) { - case '1d': - pastDate.setDate(now.getDate() - 1); - break; - case '7d': - pastDate.setDate(now.getDate() - 7); - break; - case '30d': - pastDate.setDate(now.getDate() - 30); - break; - case 'all': - default: - // No filtering for 'all' - break; + if (!MORALIS_API_KEY) { + throw new Error('Moralis API key not available'); + } + + // Convert chainId to Moralis format + const moralisChain = isBNBChain(chainId) + ? (chainId === '0x38' ? 'bsc' : 'bsc testnet') + : (chainId === '0x1' ? 'eth' : chainId === '0xaa36a7' ? 'sepolia' : 'goerli'); + + const options = { + method: 'GET', + url: `https://deep-index.moralis.io/api/v2/${address}/nft`, + params: { + chain: moralisChain, + format: 'decimal', + limit: '100', + normalizeMetadata: 'true' + }, + headers: { + accept: 'application/json', + 'X-API-Key': MORALIS_API_KEY + } + }; + + const response = await axios.request(options); + + if (response.status !== 200) { + throw new Error(`Moralis API error: ${response.status}`); + } + + const data = response.data; + + // Map Moralis NFT data to a format compatible with Alchemy's + const formattedNfts = data.result.map((item: any) => { + // Try to parse metadata + let metadata = {}; + try { + if (item.normalized_metadata) { + metadata = item.normalized_metadata; + } else if (item.metadata && typeof item.metadata === 'string') { + metadata = JSON.parse(item.metadata); + } else if (item.metadata) { + metadata = item.metadata; + } + } catch (e) { + console.warn('Error parsing Moralis NFT metadata:', e); } - // Filter and format price history - const filteredPriceData = period === 'all' - ? priceData - : priceData.filter(item => new Date(item.date) >= pastDate); - - const priceHistory = filteredPriceData.map(item => ({ - date: item.date, - price: parseFloat(item.price) - })); - - // Calculate statistics - const latestPrice = priceHistory.length > 0 - ? priceHistory[priceHistory.length - 1].price - : 0; - - const oldestPrice = priceHistory.length > 0 - ? priceHistory[0].price - : latestPrice; - - const priceChange = oldestPrice > 0 - ? ((latestPrice - oldestPrice) / oldestPrice) * 100 - : 0; - - // Get collection info for additional stats - const collection = await fetchCollectionInfo(contractAddress, chainId); - - // Generate realistic mock data - const totalSupply = parseInt(collection.totalSupply) || 10000; - const ownersCount = Math.floor(totalSupply * (0.3 + Math.random() * 0.4)); // 30-70% of supply - const salesCount = Math.floor(ownersCount * (0.1 + Math.random() * 0.4)); // 10-50% of owners - const volume = salesCount * latestPrice; + const imageUrl = ( + metadata && (metadata as any).image + ? (metadata as any).image.replace('ipfs://', 'https://ipfs.io/ipfs/') + : '' + ); return { - floorPrice: latestPrice, - volume: volume, - change: priceChange, - sales: salesCount, - averagePrice: volume / salesCount, - owners: ownersCount, - holders: { - count: ownersCount, - percentage: (ownersCount / totalSupply) * 100 + contract: { + address: item.token_address }, - priceHistory - }; - } catch (error) { - console.error("Error fetching collection stats:", error); - toast.error("Failed to fetch collection statistics"); - - // Return default values - return { - floorPrice: 0, - volume: 0, - change: 0, - sales: 0, - averagePrice: 0, - owners: 0, - holders: { count: 0, percentage: 0 }, - priceHistory: [] + id: { + tokenId: item.token_id + }, + title: (metadata as any)?.name || `NFT #${item.token_id}`, + description: (metadata as any)?.description || '', + tokenUri: { + gateway: item.token_uri || '' + }, + media: [{ + gateway: imageUrl + }], + metadata: metadata }; - } + }); + + return { + ownedNfts: formattedNfts, + totalCount: data.total || formattedNfts.length, + pageKey: data.cursor || undefined + }; } /** - * Get detailed NFT metadata with rarity scores + * Fetch user NFTs from Etherscan */ -export async function getNFTWithRarityScore( - contractAddress: string, - tokenId: string, - chainId: string -): Promise }> { - try { - // Get the NFT - const nft = await fetchNFTData(contractAddress, tokenId, chainId); - - if (!nft) { - throw new Error("NFT not found"); - } - - // Get all traits for the collection to calculate rarity - const collectionTraits = await fetchCollectionTraits(contractAddress, chainId); - - // Calculate rarity score for each trait - const traitRarity: Record = {}; - let totalRarityScore = 0; - - if (nft.attributes && nft.attributes.length > 0) { - nft.attributes.forEach(attr => { - if (!collectionTraits[attr.trait_type]) return; - - // Calculate trait rarity percentage - const traitValues = collectionTraits[attr.trait_type]; - const traitOccurrence = traitValues.length > 0 ? 1 / traitValues.length : 0; - - // Calculate trait rarity score (rarer = higher score) - const rarityScore = traitValues.includes(attr.value) - ? 1 / (traitValues.filter(v => v === attr.value).length / traitValues.length) - : 10; // Very rare if it's unique - - traitRarity[attr.trait_type] = rarityScore; - totalRarityScore += rarityScore; - }); +async function fetchUserNFTsFromEtherscan(address: string, chainId: string): Promise<{ + ownedNfts: any[], + totalCount: number +}> { + if (!ETHERSCAN_API_KEY) { + throw new Error('Etherscan API key not available'); + } + + if (!isEthereumChain(chainId)) { + throw new Error('Etherscan only supports Ethereum chains'); + } + + // Get appropriate Etherscan domain + let domain = 'api.etherscan.io'; + if (chainId === '0xaa36a7') { + domain = 'api-sepolia.etherscan.io'; + } else if (chainId === '0x5') { + domain = 'api-goerli.etherscan.io'; + } + + // Get ERC-721 token transfers for the address + const apiUrl = `https://${domain}/api?module=account&action=tokennfttx&address=${address}&page=1&offset=100&sort=desc&apikey=${ETHERSCAN_API_KEY}`; + + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`Etherscan API error: ${response.status}`); + } + + const data = await response.json(); + + if (data.status !== '1') { + throw new Error(`Etherscan API error: ${data.message}`); + } + + // Group transactions by token contract and ID to find current holdings + const nftHoldings = new Map(); + const transferEvents = data.result || []; + + // Process transfer events to determine current holdings + transferEvents.forEach((tx: any) => { + const contractAddress = tx.contractAddress.toLowerCase(); + const tokenId = tx.tokenID; + const key = `${contractAddress}-${tokenId}`; + + // Check if this is a transfer TO the user (current owner) + if (tx.to.toLowerCase() === address.toLowerCase()) { + if (!nftHoldings.has(key)) { + nftHoldings.set(key, { + contractAddress, + tokenId, + tokenName: tx.tokenName, + tokenSymbol: tx.tokenSymbol + }); + } + } + // Check if this is a transfer FROM the user (no longer owner) + else if (tx.from.toLowerCase() === address.toLowerCase()) { + nftHoldings.delete(key); } - - // Add missing traits as a rarity factor - const missingTraits = Object.keys(collectionTraits).filter( - trait => !nft.attributes?.some(attr => attr.trait_type === trait) - ); - - missingTraits.forEach(trait => { - traitRarity[`missing_${trait}`] = 5; // Missing traits add to rarity - totalRarityScore += 5; - }); - - // Normalize rarity score (0-100 scale) - const normalizedRarityScore = Math.min(100, totalRarityScore / (Object.keys(collectionTraits).length || 1)); - - return { - ...nft, - rarityScore: normalizedRarityScore, - traitRarity - }; - } catch (error) { - console.error("Error calculating NFT rarity:", error); - - // Return the NFT without rarity if available, otherwise rethrow - const nft = await fetchNFTData(contractAddress, tokenId, chainId); - if (nft) { + }); + + // Convert to array + const nfts = Array.from(nftHoldings.values()); + + // For each NFT, try to get additional metadata + const formattedNfts = await Promise.all(nfts.map(async (nft) => { + try { + // Try to get token URI and metadata + const provider = getChainProvider(chainId); + const abi = ["function tokenURI(uint256 tokenId) view returns (string)"]; + const contract = new ethers.Contract(nft.contractAddress, abi, provider); + + let tokenUri = ''; + try { + tokenUri = await contract.tokenURI(nft.tokenId); + } catch (e) { + console.warn(`Error getting tokenURI for ${nft.contractAddress} - ${nft.tokenId}:`, e); + } + + // Fetch metadata if tokenURI is available + let metadata = {}; + let imageUrl = ''; + if (tokenUri) { + try { + // Handle IPFS URIs + const metadataUrl = tokenUri.replace('ipfs://', 'https://ipfs.io/ipfs/'); + const metadataResponse = await fetch(metadataUrl); + if (metadataResponse.ok) { + metadata = await metadataResponse.json(); + imageUrl = (metadata as any).image?.replace('ipfs://', 'https://ipfs.io/ipfs/') || ''; + } + } catch (e) { + console.warn(`Error fetching metadata for ${nft.contractAddress} - ${nft.tokenId}:`, e); + } + } + return { - ...nft, - rarityScore: 0, - traitRarity: {} + contract: { + address: nft.contractAddress + }, + id: { + tokenId: nft.tokenId + }, + title: (metadata as any)?.name || `${nft.tokenName} #${nft.tokenId}`, + description: (metadata as any)?.description || '', + tokenUri: { + gateway: tokenUri + }, + media: [{ + gateway: imageUrl + }], + metadata + }; + } catch (e) { + console.warn(`Error processing NFT ${nft.contractAddress} - ${nft.tokenId}:`, e); + + // Return basic info without metadata + return { + contract: { + address: nft.contractAddress + }, + id: { + tokenId: nft.tokenId + }, + title: `${nft.tokenName} #${nft.tokenId}`, + description: '', + media: [{ gateway: '' }], + metadata: {} }; } - - throw error; - } + })); + + return { + ownedNfts: formattedNfts, + totalCount: formattedNfts.length + }; } /** - * Get similar NFTs to a specific NFT + * Fetch user NFTs from BSCScan */ -export async function getSimilarNFTs( - contractAddress: string, - tokenId: string, - chainId: string, - limit: number = 6 -): Promise { - try { - // Get the reference NFT - const nft = await fetchNFTData(contractAddress, tokenId, chainId); - - if (!nft || !nft.attributes) { - throw new Error("NFT not found or has no attributes"); +async function fetchUserNFTsFromBSCScan(address: string, chainId: string): Promise<{ + ownedNfts: any[], + totalCount: number +}> { + if (!BSCSCAN_API_KEY) { + throw new Error('BSCScan API key not available'); + } + + if (!isBNBChain(chainId)) { + throw new Error('BSCScan only supports BNB Chain'); + } + + // Get appropriate BSCScan domain + const domain = chainId === '0x38' ? 'api.bscscan.com' : 'api-testnet.bscscan.com'; + + // Get ERC-721 token transfers for the address + const apiUrl = `https://${domain}/api?module=account&action=tokennfttx&address=${address}&page=1&offset=100&sort=desc&apikey=${BSCSCAN_API_KEY}`; + + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`BSCScan API error: ${response.status}`); + } + + const data = await response.json(); + + if (data.status !== '1') { + throw new Error(`BSCScan API error: ${data.message}`); + } + + // Group transactions by token contract and ID to find current holdings + const nftHoldings = new Map(); + const transferEvents = data.result || []; + + // Process transfer events to determine current holdings + transferEvents.forEach((tx: any) => { + const contractAddress = tx.contractAddress.toLowerCase(); + const tokenId = tx.tokenID; + const key = `${contractAddress}-${tokenId}`; + + // Check if this is a transfer TO the user (current owner) + if (tx.to.toLowerCase() === address.toLowerCase()) { + if (!nftHoldings.has(key)) { + nftHoldings.set(key, { + contractAddress, + tokenId, + tokenName: tx.tokenName, + tokenSymbol: tx.tokenSymbol + }); + } + } + // Check if this is a transfer FROM the user (no longer owner) + else if (tx.from.toLowerCase() === address.toLowerCase()) { + nftHoldings.delete(key); } - - // Fetch a batch of NFTs from the same collection - const { nfts } = await fetchCollectionNFTs(contractAddress, chainId, { - pageSize: 50, - sortBy: 'tokenId', - sortDirection: 'asc' - }); - - // Filter out the reference NFT - const otherNFTs = nfts.filter(item => item.tokenId !== tokenId); - - // Calculate similarity score for each NFT - const scoredNFTs = otherNFTs.map(item => { - if (!item.attributes || !nft.attributes) return { nft: item, score: 0 }; + }); + + // Convert to array + const nfts = Array.from(nftHoldings.values()); + + // For each NFT, try to get additional metadata + const formattedNfts = await Promise.all(nfts.map(async (nft) => { + try { + // Try to get token URI and metadata + const provider = getChainProvider(chainId); + const abi = ["function tokenURI(uint256 tokenId) view returns (string)"]; + const contract = new ethers.Contract(nft.contractAddress, abi, provider); - let score = 0; + let tokenUri = ''; + try { + tokenUri = await contract.tokenURI(nft.tokenId); + } catch (e) { + console.warn(`Error getting tokenURI for ${nft.contractAddress} - ${nft.tokenId}:`, e); + } - // Calculate score based on matching attributes - nft.attributes.forEach(refAttr => { - const matchingAttr = item.attributes?.find(attr => - attr.trait_type === refAttr.trait_type - ); - - if (matchingAttr) { - // Direct match adds more points - if (matchingAttr.value === refAttr.value) { - score += 10; - } else { - // Same trait type but different value - score += 2; + // Fetch metadata if tokenURI is available + let metadata = {}; + let imageUrl = ''; + if (tokenUri) { + try { + // Handle IPFS URIs + const metadataUrl = tokenUri.replace('ipfs://', 'https://ipfs.io/ipfs/'); + const metadataResponse = await fetch(metadataUrl); + if (metadataResponse.ok) { + metadata = await metadataResponse.json(); + imageUrl = (metadata as any).image?.replace('ipfs://', 'https://ipfs.io/ipfs/') || ''; } + } catch (e) { + console.warn(`Error fetching metadata for ${nft.contractAddress} - ${nft.tokenId}:`, e); } - }); + } - return { nft: item, score }; - }); - - // Sort by similarity score (highest first) and take top 'limit' - return scoredNFTs - .sort((a, b) => b.score - a.score) - .slice(0, limit) - .map(item => item.nft); - } catch (error) { - console.error("Error finding similar NFTs:", error); - return []; - } + return { + contract: { + address: nft.contractAddress + }, + id: { + tokenId: nft.tokenId + }, + title: (metadata as any)?.name || `${nft.tokenName} #${nft.tokenId}`, + description: (metadata as any)?.description || '', + tokenUri: { + gateway: tokenUri + }, + media: [{ + gateway: imageUrl + }], + metadata + }; + } catch (e) { + console.warn(`Error processing NFT ${nft.contractAddress} - ${nft.tokenId}:`, e); + + // Return basic info without metadata + return { + contract: { + address: nft.contractAddress + }, + id: { + tokenId: nft.tokenId + }, + title: `${nft.tokenName} #${nft.tokenId}`, + description: '', + media: [{ gateway: '' }], + metadata: {} + }; + } + })); + + return { + ownedNfts: formattedNfts, + totalCount: formattedNfts.length + }; } /** - * Get estimated NFT value based on traits and recent sales + * Fetch popular NFT collections for a specific chain */ -export async function estimateNFTValue( - contractAddress: string, - tokenId: string, - chainId: string -): Promise<{ estimatedValue: number; confidenceScore: number; similarSales: any[] }> { +export async function fetchPopularCollections(chainId: string): Promise { try { - // Get NFT with rarity info - const nftWithRarity = await getNFTWithRarityScore(contractAddress, tokenId, chainId); - - // Get collection floor price - const stats = await getCollectionStats(contractAddress, chainId, '7d'); + const cacheKey = `popular-collections-${chainId}`; - // Get similar NFTs to compare - const similarNFTs = await getSimilarNFTs(contractAddress, tokenId, chainId, 10); + // Check cache first + if (collectionsCache.has(cacheKey)) { + return collectionsCache.get(cacheKey); + } - // Mock sale data for similar NFTs - const similarSales = similarNFTs.map(nft => { - // Generate realistic sale price based on collection floor and rarity - const rarityFactor = 0.8 + Math.random() * 0.4; // 0.8-1.2 range - const priceVariance = stats.floorPrice * rarityFactor; - - return { - tokenId: nft.tokenId, - name: nft.name, - price: priceVariance.toFixed(3), - date: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString() // Random date in last 30 days - }; - }); + // Get list of popular collection addresses for this chain + const popularCollections = POPULAR_NFT_COLLECTIONS[chainId as keyof typeof POPULAR_NFT_COLLECTIONS] || []; - // Calculate estimated value based on rarity score and floor price - const rarityMultiplier = (nftWithRarity.rarityScore / 50) + 0.5; // 0.5-2.5x based on rarity - let estimatedValue = stats.floorPrice * rarityMultiplier; + // Fetch detailed info for each collection + const collectionsPromises = popularCollections.map(collection => + fetchCollectionInfo(collection.address, chainId) + ); - // Floor price safeguard - estimatedValue = Math.max(estimatedValue, stats.floorPrice * 0.8); + const collectionsData = await Promise.all(collectionsPromises); - // Confidence score based on available data - const confidenceScore = Math.min(85, 40 + (similarNFTs.length * 5)); + // Cache the results + collectionsCache.set(cacheKey, collectionsData); - return { - estimatedValue, - confidenceScore, - similarSales - }; + return collectionsData; } catch (error) { - console.error("Error estimating NFT value:", error); - return { - estimatedValue: 0, - confidenceScore: 0, - similarSales: [] - }; + console.error('Error fetching popular collections:', error); + return []; } } -/** - * Enhanced NFT fetching service with caching, pagination and virtualization support - */ -export async function fetchNFTsWithVirtualization( - contractAddress: string, - chainId: string, - page: number = 1, - pageSize: number = DEFAULT_PAGE_SIZE, - sortBy: string = 'tokenId', - sortDirection: 'asc' | 'desc' = 'asc', - searchQuery: string = '', - attributes: Record = {} -): Promise<{ - nfts: CollectionNFT[], - totalCount: number, - hasMore: boolean, - pageKey?: string -}> { - // Create a cache key based on all parameters - const cacheKey = `${contractAddress}-${chainId}-${page}-${pageSize}-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; - // Check if we have cached data and it's not expired - const cachedData = nftCache.get(cacheKey); - if (cachedData && Date.now() - cachedData.timestamp < cachedData.expires) { - return { - nfts: cachedData.data, - totalCount: cachedData.totalCount, - hasMore: cachedData.data.length < cachedData.totalCount - }; - } - try { - const response = await fetchCollectionNFTs(contractAddress, chainId, { - page, - pageSize, - sortBy, - sortDirection, - searchQuery, - attributes - }); +import { + fetchCollectionNFTs as _duplicateAlchemyFetchCollectionNFTs, + fetchUserNFTs as alchemyFetchUserNFTs +} from './alchemyNFTApi'; - const nftsWithChain = response.nfts.map(nft => ({ - ...nft, - chain: chainId - })); - - // Store in cache - nftCache.set(cacheKey, { - data: nftsWithChain, - totalCount: response.totalCount, - timestamp: Date.now(), - expires: CACHE_TTL - }); - return { - nfts: nftsWithChain, - totalCount: response.totalCount, - hasMore: response.nfts.length < response.totalCount, - pageKey: response.pageKey - }; - } catch (error) { - console.error('Error fetching NFTs with virtualization:', error); - toast.error('Failed to load NFTs. Please try again.'); - return { nfts: [], totalCount: 0, hasMore: false }; - } +// Cache system for NFTs +const NFT_CACHE = new Map(); +const PAGINATION_CACHE = new Map(); +const COLLECTION_CACHE = new Map(); + +// Cache TTL settings +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds +const PAGINATION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds for pagination cache +const MEMORY_ESTIMATE_FACTOR = 6000; // bytes per NFT (rough estimate including image URLs and metadata) + +// Progressive loading state +type OnProgressCallback = (loaded: number, total: number) => void; +interface ProgressiveLoadingOptions { + batchSize?: number; + initialPageSize?: number; + maxBatches?: number; + sortBy?: string; + sortDirection?: 'asc' | 'desc'; + searchQuery?: string; + attributes?: Record; + onProgress?: OnProgressCallback; +} + +// Interface for NFT Collection response +interface NFTResponse { + nfts: any[]; + totalCount: number; + nextCursor?: string; + hasMoreBatches?: boolean; + progress: number; } /** - * Get cursor-based paginated NFTs similar to OpenSea + * Fetch NFTs with optimized cursor-based pagination */ -export async function fetchNFTsWithCursor( +export async function fetchNFTsWithOptimizedCursor( contractAddress: string, chainId: string, cursor?: string, - limit: number = DEFAULT_PAGE_SIZE, + pageSize: number = 50, sortBy: string = 'tokenId', sortDirection: 'asc' | 'desc' = 'asc', searchQuery: string = '', attributes: Record = {} -): Promise<{ - nfts: any[], - totalCount: number, - nextCursor?: string -}> { - // Calculate the "page" based on cursor if provided - // This is a simplified approach - in a real app you'd parse the cursor - const page = cursor ? parseInt(cursor, 10) : 1; +): Promise { + // Generate cache key based on all parameters + const cacheKey = `${contractAddress}-${chainId}-${cursor}-${pageSize}-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; + + // Check cache first + const cached = NFT_CACHE.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + console.log('Using cached NFT data for', cacheKey); + return { ...cached.data, progress: 100 }; + } try { - const response = await fetchCollectionNFTs(contractAddress, chainId, { - page, - pageSize: limit, - sortBy, - sortDirection, - searchQuery, - attributes - }); + // Check if this is a synthetic cursor for offset-based pagination + let page: number | undefined; + let startTokenId: number | undefined; + + if (cursor && cursor.startsWith('synthetic:')) { + const parts = cursor.split(':'); + + if (parts.length >= 3) { + if (parts[1] === 'page') { + // This is a page-based synthetic cursor + page = parseInt(parts[2]); + console.log(`Using synthetic page cursor: ${page}`); + } else if (parts[1] === 'tokenId') { + // This is a token ID based cursor + startTokenId = parseInt(parts[2]); + console.log(`Using synthetic tokenId cursor starting from: ${startTokenId}`); + } + } + } else if (cursor === '1') { + // First page + page = 1; + } - // Create a new cursor for the next page - const nextCursor = response.pageKey || ( - response.nfts.length === limit ? (page + 1).toString() : undefined + // Fetch data from API - Now passing startTokenId correctly + const result = await fetchCollectionNFTs( + contractAddress, + chainId, + { + page, + pageSize, + sortBy, + sortDirection, + searchQuery, + attributes, + pageKey: cursor && !cursor.startsWith('synthetic:') && cursor !== '1' ? cursor : undefined, + startTokenId + } ); - return { - nfts: response.nfts, - totalCount: response.totalCount, - nextCursor + // Calculate progress (rough estimate) + const progress = Math.min(100, Math.round((result.nfts.length / (result.totalCount || 1)) * 100)); + + // Generate a new synthetic cursor based on the last NFT in this batch + let syntheticCursor: string | undefined; + + if (result.pageKey) { + // Use the provided pageKey if available (from API) + syntheticCursor = result.pageKey; + } else if (result.nfts.length > 0) { + // Generate our own cursor using the last NFT in the result set + if (sortBy === 'tokenId') { + // Get the last token ID + const lastNft = result.nfts[result.nfts.length - 1]; + if (lastNft && lastNft.tokenId) { + const lastTokenId = parseInt(lastNft.tokenId); + if (!isNaN(lastTokenId)) { + // For next page, we want to start just after the last token + const nextTokenId = lastTokenId + 1; + syntheticCursor = `synthetic:tokenId:${nextTokenId}:${sortDirection}`; + console.log(`Generated new token-based cursor: ${syntheticCursor}`); + } + } + } else if (page) { + // For other sort types using page-based approach + syntheticCursor = `synthetic:page:${page + 1}`; + console.log(`Generated page-based cursor: ${syntheticCursor}`); + } + } + + // If we couldn't generate a cursor but have results, fallback to a page-based cursor + if (!syntheticCursor && result.nfts.length > 0) { + // We have results but no cursor - create a page-based one + const currentPage = page || 1; + syntheticCursor = `synthetic:page:${currentPage + 1}`; + console.log(`Fallback to page-based cursor: ${syntheticCursor}`); + } + + const response = { + nfts: result.nfts, + totalCount: result.totalCount, + nextCursor: syntheticCursor, + progress }; + + // Cache the response + NFT_CACHE.set(cacheKey, { data: response, timestamp: Date.now() }); + + return response; } catch (error) { - console.error('Error fetching NFTs with cursor:', error); - toast.error('Failed to load NFTs. Please try again.'); - return { nfts: [], totalCount: 0 }; - } -} - -/** - * Clear all NFT cache data - */ -export function clearNFTCache() { - nftCache.clear(); - // Also clear advanced cache - advancedNFTCache.clearAll(); -} - -/** - * Clear cache for a specific collection - */ -export function clearCollectionCache(contractAddress: string, chainId: string) { - const cacheKeyPrefix = `${contractAddress}-${chainId}`; - - // Iterate through all keys and delete matching ones - for (const key of nftCache.keys()) { - if (key.startsWith(cacheKeyPrefix)) { - nftCache.delete(key); - } + console.error('Error in fetchNFTsWithOptimizedCursor:', error); + throw error; } } /** - * Get NFT indexing status - simulating OpenSea's indexing progress + * Generate a synthetic cursor when API doesn't provide one + * This helps with collections that don't support cursor-based pagination */ -export async function getNFTIndexingStatus(contractAddress: string, chainId: string): Promise<{ - status: 'completed' | 'in_progress' | 'not_started', - progress: number -}> { - // Simulate different statuses based on contract address - const lastChar = contractAddress.slice(-1); - const charCode = lastChar.charCodeAt(0); - - if (charCode % 3 === 0) { - return { status: 'completed', progress: 100 }; - } else if (charCode % 3 === 1) { - const progress = Math.floor(Math.random() * 90) + 10; // 10-99% - return { status: 'in_progress', progress }; - } else { - return { status: 'not_started', progress: 0 }; +function generateSyntheticCursor( + nfts: any[], + sortBy: string = 'tokenId', + sortDirection: 'asc' | 'desc' = 'asc' +): string | undefined { + // If no NFTs, there's no next page + if (!nfts || nfts.length === 0) { + return undefined; } -} - -/** - * Calculate visible range for virtualized rendering - */ -export function calculateVisibleRange( - scrollTop: number, - viewportHeight: number, - itemHeight: number, - itemCount: number, - buffer: number = 5 // Number of items to render above/below viewport -): { startIndex: number, endIndex: number } { - const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer); - const endIndex = Math.min( - itemCount - 1, - Math.ceil((scrollTop + viewportHeight) / itemHeight) + buffer - ); - return { startIndex, endIndex }; -} - -/** - * Generate placeholder data for NFTs that are being loaded - */ -export function generatePlaceholderNFTs(count: number, startIndex: number = 0): any[] { - return Array.from({ length: count }, (_, i) => ({ - id: `placeholder-${startIndex + i}`, - tokenId: `${startIndex + i}`, - name: `Loading...`, - description: '', - imageUrl: '', - isPlaceholder: true, - attributes: [] - })); -} - -// Create a more sophisticated cache system with IndexedDB support -class AdvancedNFTCache { - private memoryCache: Map = new Map(); - - private readonly MEMORY_CACHE_LIMIT = 1000; // Max items to store in memory - private readonly DB_NAME = 'nft_cache_db'; - private readonly STORE_NAME = 'nfts'; - private dbPromise: Promise | null = null; - - constructor() { - // Initialize IndexedDB for large collections - this.initDB(); - } - - private initDB(): Promise { - if (this.dbPromise) return this.dbPromise; - - this.dbPromise = new Promise((resolve, reject) => { - if (!window.indexedDB) { - console.warn('IndexedDB not supported. Using memory cache only.'); - resolve(null as unknown as IDBDatabase); - return; - } - - const request = window.indexedDB.open(this.DB_NAME, 1); - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(this.STORE_NAME)) { - const store = db.createObjectStore(this.STORE_NAME, { keyPath: 'cacheKey' }); - store.createIndex('timestamp', 'timestamp', { unique: false }); + // For token ID based sorting, create a cursor based on the last token ID + if (sortBy === 'tokenId') { + const lastNft = sortDirection === 'asc' ? nfts[nfts.length - 1] : nfts[0]; + if (lastNft && lastNft.tokenId) { + // For ascending, next token would be lastTokenId + 1 + // For descending, next token would be lastTokenId - 1 + const lastTokenId = parseInt(lastNft.tokenId); + if (!isNaN(lastTokenId)) { + const nextId = sortDirection === 'asc' ? lastTokenId + 1 : lastTokenId - 1; + // Only return a synthetic cursor if nextId is positive (valid) + if (nextId >= 0) { + return `synthetic:${sortBy}:${nextId}:${sortDirection}`; } - }; - - request.onsuccess = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - resolve(db); - }; - - request.onerror = (event) => { - console.error('IndexedDB error:', event); - reject(new Error('Failed to open IndexedDB')); - }; - }); - - return this.dbPromise; - } - - async get(key: string): Promise<{ - data: any[]; - totalCount: number; - timestamp: number; - expires: number; - } | null> { - // First check memory cache - if (this.memoryCache.has(key)) { - return this.memoryCache.get(key) || null; - } - - // Then check IndexedDB for large collections - try { - const db = await this.initDB(); - if (!db) return null; - - return new Promise((resolve) => { - const transaction = db.transaction(this.STORE_NAME, 'readonly'); - const store = transaction.objectStore(this.STORE_NAME); - const request = store.get(key); - - request.onsuccess = () => { - const result = request.result; - if (result && Date.now() - result.timestamp < result.expires) { - // Cache hit - move to memory for faster access next time - this.memoryCache.set(key, result); - this.pruneMemoryCache(); - resolve(result); - } else { - resolve(null); - } - }; - - request.onerror = () => resolve(null); - }); - } catch (error) { - console.warn('Error accessing IndexedDB:', error); - return null; - } - } - - async set(key: string, value: { - data: any[]; - totalCount: number; - timestamp: number; - expires: number; - }, isLargeCollection: boolean = false): Promise { - // Always store in memory cache for fast access - this.memoryCache.set(key, value); - this.pruneMemoryCache(); - - // For large collections, also persist to IndexedDB - if (isLargeCollection) { - try { - const db = await this.initDB(); - if (!db) return; - - const transaction = db.transaction(this.STORE_NAME, 'readwrite'); - const store = transaction.objectStore(this.STORE_NAME); - store.put({ ...value, cacheKey: key }); - } catch (error) { - console.warn('Error storing in IndexedDB:', error); - } - } - } - - async clearForCollection(collectionId: string, chainId: string): Promise { - const keyPrefix = `${collectionId}-${chainId}`; - - // Clear from memory cache - for (const key of this.memoryCache.keys()) { - if (key.startsWith(keyPrefix)) { - this.memoryCache.delete(key); } } - - // Clear from IndexedDB - try { - const db = await this.initDB(); - if (!db) return; - - const transaction = db.transaction(this.STORE_NAME, 'readwrite'); - const store = transaction.objectStore(this.STORE_NAME); - const range = IDBKeyRange.bound( - keyPrefix, - keyPrefix + '\uffff', // This ensures we get all keys starting with keyPrefix - false, - false - ); - - store.delete(range); - } catch (error) { - console.warn('Error clearing IndexedDB:', error); - } } - async clearAll(): Promise { - // Clear memory cache - this.memoryCache.clear(); - - // Clear IndexedDB - try { - const db = await this.initDB(); - if (!db) return; - - const transaction = db.transaction(this.STORE_NAME, 'readwrite'); - const store = transaction.objectStore(this.STORE_NAME); - store.clear(); - } catch (error) { - console.warn('Error clearing IndexedDB:', error); - } + // For other sort types, just indicate there might be a next page if we have a full page + if (nfts.length >= 20) { // Assume a full page is at least 20 items + return 'synthetic:nextpage'; } - private pruneMemoryCache(): void { - // Keep memory cache size under control - if (this.memoryCache.size > this.MEMORY_CACHE_LIMIT) { - // Remove oldest entries - const entries = Array.from(this.memoryCache.entries()); - entries.sort((a, b) => a[1].timestamp - b[1].timestamp); - - const toDelete = entries.slice(0, entries.length - this.MEMORY_CACHE_LIMIT); - for (const [key] of toDelete) { - this.memoryCache.delete(key); - } - } - } + return undefined; } -// Create a singleton instance of our advanced cache -const advancedNFTCache = new AdvancedNFTCache(); - -// Track loading state for collections to avoid duplicate requests -const loadingCollections = new Map>(); - /** - * Enhanced NFT fetching with progressive loading for very large collections + * Fetch NFTs using progressive loading to load all items in batches */ export async function fetchNFTsWithProgressiveLoading( contractAddress: string, chainId: string, - options: { - batchSize?: number; - maxBatches?: number; // Limit number of batches to avoid excessive loading - initialPage?: number; - initialPageSize?: number; - sortBy?: string; - sortDirection?: 'asc' | 'desc'; - searchQuery?: string; - attributes?: Record; - onProgress?: (progress: number, total: number) => void; - } = {} -): Promise<{ - nfts: any[]; - totalCount: number; - hasMoreBatches: boolean; - progress: number; // 0-100 -}> { + options: ProgressiveLoadingOptions = {} +): Promise { const { - batchSize = 100, - maxBatches = 100, // Limit to 10,000 NFTs by default - initialPage = 1, - initialPageSize = 32, + batchSize = 50, + initialPageSize = 50, + maxBatches = 5, sortBy = 'tokenId', sortDirection = 'asc', searchQuery = '', @@ -1487,49 +1979,64 @@ export async function fetchNFTsWithProgressiveLoading( onProgress } = options; - // Determine if this is a large collection (>500 items) - const isLargeCollection = true; // Assume large until we know otherwise - - // Create a cache key for this specific request - const cacheKeyBase = `${contractAddress}-${chainId}-progressive`; - const filterKey = `-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; - const cacheKey = cacheKeyBase + filterKey; + // Generate cache key + const cacheKey = `progressive-${contractAddress}-${chainId}-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; - // Check if we already have cached data - const cachedData = await advancedNFTCache.get(cacheKey); - if (cachedData) { - // Check if cache is fresh enough - const now = Date.now(); - if (now - cachedData.timestamp < cachedData.expires) { - return { - nfts: cachedData.data, - totalCount: cachedData.totalCount, - hasMoreBatches: cachedData.data.length < cachedData.totalCount, - progress: (cachedData.data.length / cachedData.totalCount) * 100 - }; - } + // Check cache first + const cached = NFT_CACHE.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + console.log('Using cached progressive NFT data for', cacheKey); + if (onProgress) onProgress(cached.data.nfts.length, cached.data.totalCount); + return { ...cached.data, progress: 100 }; } - // Check if this collection is already being loaded - const loadingKey = `${contractAddress}-${chainId}-loading`; - if (loadingCollections.has(loadingKey)) { - try { - await loadingCollections.get(loadingKey); - } catch (error) { - console.warn('Previous loading failed:', error); + // Initial load + let result = await fetchCollectionNFTs( + contractAddress, + chainId, + { + page: 1, + pageSize: initialPageSize, + sortBy, + sortDirection, + searchQuery, + attributes } + ); + + const allNfts = [...result.nfts]; + const totalCount = result.totalCount || 0; + + // Don't attempt to load more if there's no pageKey or the collection is small + if (!result.pageKey || allNfts.length >= totalCount || allNfts.length >= batchSize * maxBatches) { + const progress = Math.min(100, Math.round((allNfts.length / (totalCount || 1)) * 100)); + const response = { + nfts: allNfts, + totalCount, + hasMoreBatches: false, + progress + }; + + NFT_CACHE.set(cacheKey, { data: response, timestamp: Date.now() }); + + if (onProgress) onProgress(allNfts.length, totalCount); + return response; } - // Set up loading promise - const loadingPromise = (async () => { + // Progressive loading with batches + let pageKey = result.pageKey; + let batchCount = 1; + let hasMoreBatches = true; + + while (pageKey && batchCount < maxBatches && allNfts.length < totalCount) { try { - // Start with a small initial batch for fast first render - const initialBatch = await fetchCollectionNFTs( + // Simulate progressive loading by using pageKey as an ID + result = await fetchCollectionNFTs( contractAddress, chainId, { - page: initialPage, - pageSize: initialPageSize, + pageKey, + pageSize: batchSize, sortBy, sortDirection, searchQuery, @@ -1537,501 +2044,512 @@ export async function fetchNFTsWithProgressiveLoading( } ); - // Store the initial NFTs - let allNfts = initialBatch.nfts; - const totalCount = initialBatch.totalCount; + allNfts.push(...result.nfts); + // Update progress callback if (onProgress) { onProgress(allNfts.length, totalCount); } - // Store in cache even with partial data - await advancedNFTCache.set(cacheKey, { - data: allNfts.map(nft => ({ ...nft, chain: chainId })), - totalCount: totalCount, - timestamp: Date.now(), - expires: 10 * 60 * 1000 // 10 minute cache - }, isLargeCollection); - - // Stop if we already have all NFTs or reached the limit - if (allNfts.length >= totalCount || allNfts.length >= batchSize * maxBatches) { - return { - nfts: allNfts, - totalCount - }; - } - - // Load remaining batches in the background - const loadRemainingBatches = async () => { - try { - let currentPage = 2; // Start from page 2 since we already have page 1 - let continueFetching = true; - - while ( - continueFetching && - allNfts.length < totalCount && - allNfts.length < batchSize * maxBatches - ) { - const nextBatch = await fetchCollectionNFTs( - contractAddress, - chainId, - { - page: currentPage, - pageSize: batchSize, - sortBy, - sortDirection, - searchQuery, - attributes - } - ); - - if (nextBatch.nfts.length === 0) { - continueFetching = false; - } else { - // Add new NFTs to our collection - allNfts = [...allNfts, ...nextBatch.nfts]; - currentPage++; - - if (onProgress) { - onProgress(allNfts.length, totalCount); - } - - // Update cache with each batch - await advancedNFTCache.set(cacheKey, { - data: allNfts.map(nft => ({ ...nft, chain: chainId })), - totalCount: totalCount, - timestamp: Date.now(), - expires: 10 * 60 * 1000 // 10 minute cache - }, isLargeCollection); - } - } - } catch (error) { - console.error('Error loading remaining batches:', error); - } - }; - - // Start the background loading process without awaiting it - loadRemainingBatches(); + // Update pageKey for next batch + pageKey = result.pageKey || ''; + batchCount++; - return { - nfts: allNfts, - totalCount - }; + // Break if we've loaded enough or there's no more data + if (!pageKey || allNfts.length >= totalCount) { + hasMoreBatches = false; + break; + } } catch (error) { - console.error('Error in progressive loading:', error); - throw error; + console.error('Error in progressive loading batch:', error); + hasMoreBatches = true; + break; } - })(); + } - // Store the promise to track loading state - loadingCollections.set(loadingKey, loadingPromise); + // Prepare response + const progress = Math.min(100, Math.round((allNfts.length / (totalCount || 1)) * 100)); + const response = { + nfts: allNfts, + totalCount, + hasMoreBatches: !!pageKey && allNfts.length < totalCount, + progress + }; - try { - const result = await loadingPromise; - - // Convert NFTs to the expected format with chain ID - const nftsWithChain = result.nfts.map(nft => ({ - ...nft, - chain: chainId - })); - - return { - nfts: nftsWithChain, - totalCount: result.totalCount, - hasMoreBatches: nftsWithChain.length < result.totalCount, - progress: (nftsWithChain.length / result.totalCount) * 100 - }; - } finally { - // Clean up loading state - loadingCollections.delete(loadingKey); - } + // Cache the aggregated result + NFT_CACHE.set(cacheKey, { data: response, timestamp: Date.now() }); + + return response; } /** - * Get cursor-based paginated NFTs with optimized memory handling for large collections + * Fetch NFTs with standard pagination */ -export async function fetchNFTsWithOptimizedCursor( +export async function fetchPaginatedNFTs( contractAddress: string, chainId: string, - cursor?: string, - limit: number = DEFAULT_PAGE_SIZE, + page: number = 1, + pageSize: number = 20, sortBy: string = 'tokenId', sortDirection: 'asc' | 'desc' = 'asc', searchQuery: string = '', attributes: Record = {} ): Promise<{ - nfts: any[], - totalCount: number, - nextCursor?: string, - loadedCount: number, - progress: number + nfts: any[]; + totalCount: number; + currentPage: number; + totalPages: number; + cursor?: string; }> { - // Create cursor info from the cursor string - const page = cursor ? parseInt(cursor, 10) : 1; - const pageOffset = (page - 1) * limit; + // Generate cache key based on all parameters + const cacheKey = `pagination-${contractAddress}-${chainId}-${page}-${pageSize}-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; - // Create a cache key that includes all filter params - const cacheKey = `${contractAddress}-${chainId}-cursor-${pageOffset}-${limit}-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; - - // Check global cache first for this specific page - const cachedPageData = await advancedNFTCache.get(cacheKey); - if (cachedPageData && Date.now() - cachedPageData.timestamp < cachedPageData.expires) { - const nextCursor = pageOffset + limit < cachedPageData.totalCount - ? (page + 1).toString() - : undefined; - - return { - nfts: cachedPageData.data, - totalCount: cachedPageData.totalCount, - nextCursor, - loadedCount: pageOffset + cachedPageData.data.length, - progress: Math.min(100, ((pageOffset + cachedPageData.data.length) / cachedPageData.totalCount) * 100) - }; + // Check cache first + const cachedData = PAGINATION_CACHE.get(cacheKey); + if (cachedData && (Date.now() - cachedData.timestamp < PAGINATION_CACHE_TTL)) { + console.log('Using cached pagination data for page', page); + return cachedData.data; } - // Also check if we have a progressive loading cache that contains this page - const progressiveCacheKey = `${contractAddress}-${chainId}-progressive-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; - const progressiveCache = await advancedNFTCache.get(progressiveCacheKey); - - if (progressiveCache && Date.now() - progressiveCache.timestamp < progressiveCache.expires) { - const totalCount = progressiveCache.totalCount; - - // Check if the progressive cache contains the data for this page - if (pageOffset < progressiveCache.data.length) { - const pageData = progressiveCache.data.slice(pageOffset, pageOffset + limit); - const nextCursor = pageOffset + limit < totalCount - ? (page + 1).toString() - : undefined; + try { + // Special case for first page + if (page === 1) { + console.log(`Fetching page 1 with cursor '1'`); + const result = await fetchNFTsWithOptimizedCursor( + contractAddress, + chainId, + '1', + pageSize, + sortBy, + sortDirection, + searchQuery, + attributes + ); - // Cache this specific page result too - await advancedNFTCache.set(cacheKey, { - data: pageData, + const totalCount = result.totalCount || Math.max(result.nfts.length, pageSize); + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + + const paginationResult = { + nfts: result.nfts, totalCount, + currentPage: page, + totalPages, + cursor: result.nextCursor + }; + + // Cache the result + PAGINATION_CACHE.set(cacheKey, { + data: paginationResult, timestamp: Date.now(), - expires: 5 * 60 * 1000 // 5 minute cache for page results + page }); - return { - nfts: pageData, + return paginationResult; + } + + // For subsequent pages, try to find the cursor from previous page + const prevPageKey = `pagination-${contractAddress}-${chainId}-${page-1}-${pageSize}-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; + const prevPageData = PAGINATION_CACHE.get(prevPageKey); + + if (prevPageData && prevPageData.data.cursor) { + // Use the cursor from the previous page + console.log(`Found cursor for page ${page} from cache:`, prevPageData.data.cursor); + const result = await fetchNFTsWithOptimizedCursor( + contractAddress, + chainId, + prevPageData.data.cursor, + pageSize, + sortBy, + sortDirection, + searchQuery, + attributes + ); + + const totalCount = result.totalCount || Math.max(result.nfts.length, pageSize * page); + const totalPages = Math.max(page, Math.ceil(totalCount / pageSize)); + + const paginationResult = { + nfts: result.nfts, totalCount, - nextCursor, - loadedCount: Math.min(progressiveCache.data.length, pageOffset + limit), - progress: Math.min(100, (progressiveCache.data.length / totalCount) * 100) + currentPage: page, + totalPages, + cursor: result.nextCursor }; + + PAGINATION_CACHE.set(cacheKey, { + data: paginationResult, + timestamp: Date.now(), + page + }); + + return paginationResult; } - } - - // If not in cache, fetch from API - try { - const response = await fetchCollectionNFTs( - contractAddress, - chainId, - { - page, - pageSize: limit, + + // If no cursor is found from previous page, try direct synthetic pagination + if (sortBy === 'tokenId') { + // Calculate a reasonable starting token ID for this page + const startTokenId = (page - 1) * pageSize + 1; + console.log(`No cursor found. Using synthetic tokenId pagination starting from: ${startTokenId}`); + + const result = await fetchNFTsWithOptimizedCursor( + contractAddress, + chainId, + `synthetic:tokenId:${startTokenId}:${sortDirection}`, + pageSize, sortBy, sortDirection, searchQuery, attributes - } + ); + + const totalCount = result.totalCount || Math.max(result.nfts.length, pageSize * page); + const totalPages = Math.max(page, Math.ceil(totalCount / pageSize)); + + const paginationResult = { + nfts: result.nfts, + totalCount, + currentPage: page, + totalPages, + cursor: result.nextCursor + }; + + PAGINATION_CACHE.set(cacheKey, { + data: paginationResult, + timestamp: Date.now(), + page + }); + + return paginationResult; + } + + // Fallback to page-based synthetic cursor if we can't use token IDs + console.log(`Using fallback page-based pagination for page ${page}`); + const result = await fetchNFTsWithOptimizedCursor( + contractAddress, + chainId, + `synthetic:page:${page}`, + pageSize, + sortBy, + sortDirection, + searchQuery, + attributes ); - // Add chain info to each NFT - const nftsWithChain = response.nfts.map(nft => ({ - ...nft, - chain: chainId - })); + const totalCount = result.totalCount || Math.max(result.nfts.length, pageSize * page); + const totalPages = Math.max(page, Math.ceil(totalCount / pageSize)); - // Create the next cursor if there are more items - const nextCursor = response.nfts.length === limit && pageOffset + limit < response.totalCount - ? (page + 1).toString() - : undefined; + const paginationResult = { + nfts: result.nfts, + totalCount, + currentPage: page, + totalPages, + cursor: result.nextCursor + }; - // Cache this page result - await advancedNFTCache.set(cacheKey, { - data: nftsWithChain, - totalCount: response.totalCount, + PAGINATION_CACHE.set(cacheKey, { + data: paginationResult, timestamp: Date.now(), - expires: 5 * 60 * 1000 // 5 minute cache + page }); - return { - nfts: nftsWithChain, - totalCount: response.totalCount, - nextCursor, - loadedCount: pageOffset + nftsWithChain.length, - progress: Math.min(100, ((pageOffset + nftsWithChain.length) / response.totalCount) * 100) - }; + return paginationResult; } catch (error) { - console.error('Error fetching NFTs with optimized cursor:', error); - toast.error('Failed to load NFTs. Please try again.'); - return { - nfts: [], - totalCount: 0, - loadedCount: 0, - progress: 0 + console.error('Error in fetchPaginatedNFTs:', error); + + // Provide fallback with mock data + const mockData = generateMockNFTs(contractAddress, chainId, page, pageSize); + const totalEstimate = Math.max(1000, page * pageSize * 2); + const totalPages = Math.ceil(totalEstimate / pageSize); + + return { + nfts: mockData, + totalCount: totalEstimate, + currentPage: page, + totalPages, + cursor: `mock:tokenId:${(page * pageSize) + 1}:${sortDirection}` }; } } /** - * Clear all NFT cache data + * Clear the NFT cache for a specific collection */ -export function clearAllNFTCaches() { - advancedNFTCache.clearAll(); +export function clearSpecificCollectionCache( + contractAddress: string, + chainId: string +): void { + // Clear all cache entries that match the collection and chain + for (const key of NFT_CACHE.keys()) { + if (key.includes(`${contractAddress}-${chainId}`)) { + NFT_CACHE.delete(key); + } + } + + console.log(`Cleared cache for collection ${contractAddress} on chain ${chainId}`); +} + +/** + * Clear all collection caches + */ +export function clearCollectionCache( + contractAddress?: string, + chainId?: string +): void { + if (contractAddress && chainId) { + clearSpecificCollectionCache(contractAddress, chainId); + } else { + NFT_CACHE.clear(); + console.log('Cleared all NFT caches'); + } } /** - * Clear cache for a specific collection + * Clear pagination cache for a collection */ -export function clearSpecificCollectionCache(contractAddress: string, chainId: string) { - advancedNFTCache.clearForCollection(contractAddress, chainId); -// No need to redefine clearNFTCache, it's already defined above and will -// call the clearAllNFTCaches function - clearAllNFTCaches(); +export function clearPaginationCache( + contractAddress: string, + chainId: string +): void { + // Clear all pagination cache entries that match the collection and chain + for (const key of PAGINATION_CACHE.keys()) { + if (key.includes(`pagination-${contractAddress}-${chainId}`)) { + PAGINATION_CACHE.delete(key); + } + } + + console.log(`Cleared pagination cache for collection ${contractAddress} on chain ${chainId}`); } /** - * Calculate estimated memory usage for an NFT collection + * Estimate memory usage for a collection based on number of NFTs */ -export function estimateCollectionMemoryUsage(totalNFTs: number): string { - // Rough estimate: average NFT object is about 2KB - const estimatedBytes = totalNFTs * 2 * 1024; - - if (estimatedBytes < 1024 * 1024) { - return `${(estimatedBytes / 1024).toFixed(2)} KB`; - } else if (estimatedBytes < 1024 * 1024 * 1024) { - return `${(estimatedBytes / (1024 * 1024)).toFixed(2)} MB`; +export function estimateCollectionMemoryUsage( + nftCount: number +): string { + const bytesEstimate = nftCount * MEMORY_ESTIMATE_FACTOR; + + if (bytesEstimate < 1024) { + return `${bytesEstimate} bytes`; + } else if (bytesEstimate < 1024 * 1024) { + return `${(bytesEstimate / 1024).toFixed(2)} KB`; + } else if (bytesEstimate < 1024 * 1024 * 1024) { + return `${(bytesEstimate / (1024 * 1024)).toFixed(2)} MB`; } else { - return `${(estimatedBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + return `${(bytesEstimate / (1024 * 1024 * 1024)).toFixed(2)} GB`; } } /** - * Preload critical NFT data + * Fetch user's NFT collections with caching */ -export async function preloadCollectionData(contractAddress: string, chainId: string): Promise { +export async function fetchUserCollections( + address: string, + chainId: string +): Promise { + // Generate cache key + const cacheKey = `user-collections-${address}-${chainId}`; + + // Check cache first + const cached = COLLECTION_CACHE.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + console.log('Using cached user collections'); + return cached.data; + } + try { - // Preload collection metadata - const metadata = await fetchCollectionInfo(contractAddress, chainId); + // Fetch user NFTs from API + const response = await fetchUserNFTs(address, chainId); - // Preload first batch of NFTs - await fetchNFTsWithOptimizedCursor( - contractAddress, - chainId, - '1', // First page - 32, // Small batch to load quickly - 'tokenId', - 'asc' - ); + // Group NFTs by collection + const collections = new Map(); + + response.ownedNfts.forEach((nft: any) => { + const contractAddress = nft.contract?.address; + if (!contractAddress) return; + + if (!collections.has(contractAddress)) { + collections.set(contractAddress, { + contractAddress, + name: nft.contract.name || 'Unknown Collection', + symbol: nft.contract.symbol || '', + count: 0, + imageUrl: nft.media?.[0]?.gateway || '', + chain: chainId + }); + } + + const collection = collections.get(contractAddress); + collection.count++; + + // Use first NFT image if collection image is missing + if (!collection.imageUrl && nft.media?.[0]?.gateway) { + collection.imageUrl = nft.media[0].gateway; + } + }); + + const result = Array.from(collections.values()); - return true; + // Cache the result + COLLECTION_CACHE.set(cacheKey, { data: result, timestamp: Date.now() }); + + return result; } catch (error) { - console.error('Error preloading collection data:', error); - return false; + console.error('Error fetching user collections:', error); + throw error; } } -// Add these enhanced caching functions for optimized pagination - /** - * Optimized cache for pagination to minimize Alchemy API calls + * Get collection metadata with caching */ -class PagedNFTCache { - private static instance: PagedNFTCache; - private cache: Map = new Map(); - - // Longer cache time for pagination to reduce API calls further - private CACHE_TTL = 30 * 60 * 1000; // 30 minutes - - private constructor() {} - - public static getInstance(): PagedNFTCache { - if (!PagedNFTCache.instance) { - PagedNFTCache.instance = new PagedNFTCache(); - } - return PagedNFTCache.instance; - } +export async function getCollectionMetadata( + contractAddress: string, + chainId: string +): Promise { + // Generate cache key + const cacheKey = `metadata-${contractAddress}-${chainId}`; - public get(key: string) { - const cached = this.cache.get(key); - if (cached && Date.now() - cached.timestamp < cached.expires) { - return cached; - } - return null; + // Check cache first + const cached = COLLECTION_CACHE.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + console.log('Using cached collection metadata'); + return cached.data; } - public set( - key: string, - data: any[], - totalCount: number, - expires: number = this.CACHE_TTL - ) { - this.cache.set(key, { - data, - totalCount, - timestamp: Date.now(), - expires + try { + const metadata = await fetchCollectionInfo(contractAddress, chainId); + + // Add chain ID to metadata + const metadataWithChain = { + ...metadata, + chain: chainId + }; + + // Cache the result + COLLECTION_CACHE.set(cacheKey, { + data: metadataWithChain, + timestamp: Date.now() }); + + return metadataWithChain; + } catch (error) { + console.error('Error fetching collection metadata:', error); + throw error; } - - public clear(prefix?: string) { - if (prefix) { - // Clear only cache entries that start with prefix - for (const key of this.cache.keys()) { - if (key.startsWith(prefix)) { - this.cache.delete(key); - } - } - } else { - // Clear all cache - this.cache.clear(); - } +} + +/** + * Filter NFTs by attribute + */ +export function filterNFTsByAttributes( + nfts: any[], + attributes: Record +): any[] { + if (!attributes || Object.keys(attributes).length === 0) { + return nfts; } - // Prefetch adjacent pages to improve UX - public async prefetchAdjacentPages( - contractAddress: string, - chainId: string, - currentPage: number, - pageSize: number, - sortBy: string, - sortDirection: 'asc' | 'desc', - searchQuery: string = '', - attributes: Record = {} - ) { - // Only prefetch if we're not already loading/caching that page - const pagesToPrefetch = [currentPage + 1]; - - for (const page of pagesToPrefetch) { - const cacheKey = this.generateCacheKey( - contractAddress, - chainId, - page, - pageSize, - sortBy, - sortDirection, - searchQuery, - attributes + return nfts.filter(nft => { + if (!nft.attributes) return false; + + // Check if NFT matches all selected attribute filters + return Object.entries(attributes).every(([traitType, values]) => { + // Find an attribute that matches the trait type + const attribute = nft.attributes.find( + (attr: any) => attr.trait_type.toLowerCase() === traitType.toLowerCase() ); - // Only prefetch if not already in cache and page is > 0 - if (!this.get(cacheKey) && page > 0) { - // Use a low priority flag and setTimeout to not block the main thread - setTimeout(() => { - fetchCollectionNFTs(contractAddress, chainId, { - page, - pageSize, - sortBy, - sortDirection, - searchQuery, - attributes - }).then(result => { - if (result.nfts.length > 0) { - this.set(cacheKey, result.nfts, result.totalCount); - } - }).catch(err => { - console.log('Prefetch error (non-critical):', err); - }); - }, 1000); // Delay prefetch to prioritize current page - } + // If not found but filter exists, exclude NFT + if (!attribute) return false; + + // Check if attribute value is in the selected values + return values.some(value => + attribute.value.toLowerCase() === value.toLowerCase() + ); + }); + }); +} + +/** + * Sort NFTs based on criteria + */ +export function sortNFTs( + nfts: any[], + sortBy: string = 'tokenId', + sortDirection: 'asc' | 'desc' = 'asc' +): any[] { + const sortedNFTs = [...nfts]; + + sortedNFTs.sort((a, b) => { + let valueA, valueB; + + if (sortBy === 'tokenId') { + // Handle numeric tokenIds properly + valueA = parseInt(a.tokenId, 10) || 0; + valueB = parseInt(b.tokenId, 10) || 0; + } else if (sortBy === 'name') { + valueA = a.name || ''; + valueB = a.name || ''; + return sortDirection === 'asc' + ? valueA.localeCompare(valueB) + : valueB.localeCompare(valueA); + } else { + // Default fallback for unknown sort criteria + valueA = a[sortBy] || 0; + valueB = b[sortBy] || 0; } - } + + return sortDirection === 'asc' ? valueA - valueB : valueB - valueA; + }); - public generateCacheKey( - contractAddress: string, - chainId: string, - page: number, - pageSize: number, - sortBy: string, - sortDirection: 'asc' | 'desc', - searchQuery: string = '', - attributes: Record = {} - ): string { - return `${contractAddress.toLowerCase()}-${chainId}-p${page}-s${pageSize}-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; - } + return sortedNFTs; } -// Singleton instance -const pagedCache = PagedNFTCache.getInstance(); +/** + * Get collection statistics + */ +export async function getCollectionStats( + contractAddress: string, + chainId: string +): Promise { + // This would normally fetch real stats from an API + // For now, we'll return mock data + const metadata = await getCollectionMetadata(contractAddress, chainId); + + return { + floorPrice: Math.random() * 5 + 0.1, + volume24h: Math.random() * 100, + totalListings: Math.floor(Math.random() * 500), + totalOwners: Math.floor(Math.random() * 2000), + totalSupply: metadata.totalSupply || 10000, + }; +} /** - * Fetch collection NFTs with optimized pagination to reduce Alchemy API calls + * Fetch paginated NFTs for PaginatedNFTGrid component + * Specifically designed to handle the unique needs of pagination components */ -export async function fetchPaginatedNFTs( +export async function fetchPaginatedNFTsForGrid( contractAddress: string, chainId: string, page: number = 1, - pageSize: number = 20, // Default to 20 for optimal API usage + pageSize: number = 20, sortBy: string = 'tokenId', sortDirection: 'asc' | 'desc' = 'asc', searchQuery: string = '', attributes: Record = {} -): Promise<{ - nfts: any[]; - totalCount: number; - hasNextPage: boolean; - hasPrevPage: boolean; -}> { - // Generate cache key - const cacheKey = pagedCache.generateCacheKey( - contractAddress, - chainId, - page, - pageSize, - sortBy, - sortDirection, - searchQuery, - attributes - ); +) { + // Use cached data if available + const cacheKey = `pagination-${contractAddress}-${chainId}-${page}-${pageSize}-${sortBy}-${sortDirection}-${searchQuery}-${JSON.stringify(attributes)}`; - // Check cache first - log cache hits for monitoring - const cached = pagedCache.get(cacheKey); - if (cached) { - console.log(`[API Optimization] Cache hit for ${contractAddress} page ${page}`); - - // Only prefetch next page if we're in a stable view (not actively changing pages) - setTimeout(() => { - // Start prefetching next page in background - pagedCache.prefetchAdjacentPages( - contractAddress, - chainId, - page, - pageSize, - sortBy, - sortDirection, - searchQuery, - attributes - ); - }, 500); - - // Ensure totalCount is at least 1 more than current items if we need pagination - const calculatedTotalCount = Math.max( - cached.totalCount, - page * pageSize + (cached.data.length === pageSize ? 1 : 0) - ); - - console.log(`[Pagination] Using cached data: Page ${page}, Items: ${cached.data.length}, Total: ${calculatedTotalCount}`); - - return { - nfts: cached.data, - totalCount: calculatedTotalCount, - hasNextPage: page * pageSize < calculatedTotalCount, - hasPrevPage: page > 1 - }; + const cachedData = PAGINATION_CACHE.get(cacheKey); + if (cachedData && (Date.now() - cachedData.timestamp < PAGINATION_CACHE_TTL)) { + return cachedData.data; } - // If not in cache, fetch from API with a small cooldown to prevent rate limiting try { - console.log(`[API Call] Fetching ${contractAddress} page ${page} - reducing API usage`); - - // Add a small random delay to prevent rate limiting (50-150ms) - await new Promise(resolve => setTimeout(resolve, 50 + Math.random() * 100)); + // Calculate start index based on page + const startIndex = (page - 1) * pageSize; const result = await fetchCollectionNFTs( contractAddress, @@ -2046,117 +2564,151 @@ export async function fetchPaginatedNFTs( } ); - // Enhance with chain info - const nftsWithChain = result.nfts.map(nft => ({ - ...nft, - chain: chainId - })); - - // If this collection is one of the large ones with known pagination issues, - // ensure we have a reasonable totalCount for pagination - let calculatedTotalCount = result.totalCount; - - // For some collections, ensure we have at least the minimum page count - const isSpecialCollection = [ - '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d', // BAYC - '0x60e4d786628fea6478f785a6d7e704777c86a7c6', // MAYC - // Add other collections with pagination issues here - ].includes(contractAddress.toLowerCase()); - - if (isSpecialCollection) { - // Ensure we have at least 100 items for these collections to show pagination - calculatedTotalCount = Math.max(calculatedTotalCount, 100); - } - - // If we got a full page of results, assume there's at least one more page - if (nftsWithChain.length === pageSize && calculatedTotalCount <= page * pageSize) { - calculatedTotalCount = page * pageSize + 1; - } - - console.log(`[Pagination] API data: Page ${page}, Items: ${nftsWithChain.length}, Adjusted Total: ${calculatedTotalCount}`); + // Ensure we have a valid totalCount - default to at least the length of returned NFTs + const totalCount = result.totalCount || Math.max(result.nfts.length, startIndex + pageSize); + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); - // Save to cache with longer TTL for popular collections - const cacheTTL = isSpecialCollection ? 60 * 60 * 1000 : 30 * 60 * 1000; // 1 hour for popular vs 30 min - pagedCache.set(cacheKey, nftsWithChain, calculatedTotalCount, cacheTTL); + const paginationResult = { + nfts: result.nfts, + currentPage: page, + totalPages, + totalCount + }; - // Prefetch adjacent pages in background with delay to ensure current page loads first - setTimeout(() => { - pagedCache.prefetchAdjacentPages( - contractAddress, - chainId, - page, - pageSize, - sortBy, - sortDirection, - searchQuery, - attributes - ); - }, 1000); + // Cache the result + PAGINATION_CACHE.set(cacheKey, { + data: paginationResult, + timestamp: Date.now(), + page: page + }); - return { - nfts: nftsWithChain, - totalCount: calculatedTotalCount, - hasNextPage: page * pageSize < calculatedTotalCount, - hasPrevPage: page > 1 - }; + return paginationResult; } catch (error) { - console.error('Error fetching paginated NFTs:', error); - toast.error('Failed to load NFTs. Please try again.'); + console.error('Error in fetchPaginatedNFTs:', error); + + // Provide fallback with mock data + const mockData = generateMockNFTs(contractAddress, chainId, page, pageSize); + // Ensure we have a reasonable total for mocks - at least 50x the page size + const totalCount = 1000; // Mock total + const totalPages = Math.ceil(totalCount / pageSize); return { - nfts: [], - totalCount: 0, - hasNextPage: false, - hasPrevPage: page > 1 + nfts: mockData, + currentPage: page, + totalPages, + totalCount }; } } /** - * Clear pagination cache for a specific collection or all collections + * Generate mock NFTs for testing or when API calls fail */ -export function clearPaginationCache(contractAddress?: string, chainId?: string) { - if (contractAddress && chainId) { - pagedCache.clear(`${contractAddress.toLowerCase()}-${chainId}`); - } else { - pagedCache.clear(); +export function generateMockNFTs(contractAddress: string, chainId: string, page: number, pageSize: number): any[] { + const nfts: any[] = []; + const startIndex = (page - 1) * pageSize + 1; + + // Normalized contract address + const normalizedAddress = contractAddress.toLowerCase(); + + // Generate NFTs for this page + for (let i = 0; i < pageSize; i++) { + const tokenId = String(startIndex + i); + + // Generate deterministic but varied attributes based on token ID + const tokenNum = parseInt(tokenId, 10); + const seed = tokenNum % 100; + + // Background options + const backgrounds = ['Blue', 'Red', 'Green', 'Purple', 'Gold', 'Black', 'White']; + const backgroundIndex = seed % backgrounds.length; + + // Species options + const species = ['Human', 'Ape', 'Robot', 'Alien', 'Zombie', 'Demon', 'Angel']; + const speciesIndex = (seed * 3) % species.length; + + // Rarity options + const rarities = ['Common', 'Uncommon', 'Rare', 'Epic', 'Legendary']; + const rarityIndex = Math.floor(seed / 20); // 0-4 + + // For special collections, use customized naming + const name = normalizedAddress === '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551' + ? `CryptoPath Genesis #${tokenId}` + : `NFT #${tokenId}`; + + const description = normalizedAddress === '0x2ff12fe4b3c4dea244c4bdf682d572a90df3b551' + ? `A unique NFT from the CryptoPath Genesis Collection with ${rarities[rarityIndex]} rarity.` + : `NFT #${tokenId} from the collection`; + + nfts.push({ + id: `${contractAddress.toLowerCase()}-${tokenId}`, + tokenId: tokenId, + name: name, + description: description, + imageUrl: `/Img/nft/sample-${(seed % 5) + 1}.jpg`, // Using sample images 1-5 + attributes: [ + { trait_type: 'Background', value: backgrounds[backgroundIndex] }, + { trait_type: 'Species', value: species[speciesIndex] }, + { trait_type: 'Rarity', value: rarities[rarityIndex] }, + // Network attribute for filtering + { trait_type: 'Network', value: chainId === '0x1' ? 'Ethereum' : + chainId === '0xaa36a7' ? 'Sepolia' : + chainId === '0x38' ? 'BNB Chain' : 'BNB Testnet' } + ], + chain: chainId + }); } + + return nfts; } /** - * Throttled API call for collections to avoid rate limiting + * Apply filtering to NFTs + * Enhances filtering logic to work with API-fetched data */ -const pendingApiCalls = new Map>(); - -export async function throttledApiCall( - key: string, - apiFunction: () => Promise, - expiryMs: number = 10000 // Default 10s -): Promise { - // Check if there's already a pending call for this key - if (pendingApiCalls.has(key)) { - return pendingApiCalls.get(key)!; - } - - // Create a new promise for this call - const promise = new Promise((resolve, reject) => { - setTimeout(async () => { - try { - const result = await apiFunction(); - resolve(result); - } catch (error) { - reject(error); - } finally { - // Auto-clean the pendingApiCalls map after expiry - setTimeout(() => { - pendingApiCalls.delete(key); - }, expiryMs); - } - }, Math.random() * 100); // Small random delay to prevent concurrent calls - }); +export function applyFilters( + nfts: any[], + searchQuery: string = '', + attributes: Record = {} +): any[] { + let filtered = [...nfts]; - // Store the promise in the map - pendingApiCalls.set(key, promise); + // Apply search filter first + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(nft => + nft.name?.toLowerCase().includes(query) || + nft.tokenId?.toString().toLowerCase().includes(query) || + nft.description?.toLowerCase().includes(query) + ); + } + + // Then apply attribute filters if any + if (Object.keys(attributes).length > 0) { + filtered = filtered.filter(nft => { + // Skip NFTs with no attributes if we're filtering by attributes + if (!nft.attributes || !Array.isArray(nft.attributes)) return false; + + // Check if all attribute filters are satisfied + return Object.entries(attributes).every(([traitType, values]) => { + // If no values selected for this trait, skip this filter + if (!values || values.length === 0) return true; + + // Find the matching attribute for this trait type + const matchingAttribute = nft.attributes.find((attr: any) => + attr.trait_type?.toLowerCase() === traitType.toLowerCase() + ); + + // If trait not found, filter out + if (!matchingAttribute) return false; + + // Check if the attribute value is in our selected values + return values.some(value => + matchingAttribute.value?.toString().toLowerCase() === value.toLowerCase() + ); + }); + }); + } - return promise; + return filtered; } diff --git a/next.config.js b/next.config.js index 91ce5ee..3c8048c 100644 --- a/next.config.js +++ b/next.config.js @@ -265,10 +265,7 @@ const nextConfig = { return config; }, // Add important settings for Vercel deployment - experimental: { - // Allow more time for API routes that make external calls - serverComponentsExternalPackages: [], - }, + // experimental options removed as they are not needed // Add extra security headers async headers() { return [ diff --git a/package-lock.json b/package-lock.json index b36611a..017d3ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,7 @@ "loading-spinner": "^1.2.1", "lucide-react": "^0.475.0", "neo4j-driver": "^5.28.1", - "next": "^15.2.3", + "next": "^15.2.4", "nodemailer": "^6.10.0", "particles.js": "^2.0.0", "pino-pretty": "^13.0.0", @@ -515,12 +515,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", - "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.10" + "@babel/types": "^7.27.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -626,14 +626,14 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -667,9 +667,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", - "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -2892,9 +2892,9 @@ } }, "node_modules/@next/env": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.3.tgz", - "integrity": "sha512-a26KnbW9DFEUsSxAxKBORR/uD9THoYoKbkpFywMN/AFvboTt94b8+g/07T8J6ACsdLag8/PDU60ov4rPxRAixw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", + "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2908,9 +2908,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.3.tgz", - "integrity": "sha512-uaBhA8aLbXLqwjnsHSkxs353WrRgQgiFjduDpc7YXEU0B54IKx3vU+cxQlYwPCyC8uYEEX7THhtQQsfHnvv8dw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", + "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", "cpu": [ "arm64" ], @@ -2924,9 +2924,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.3.tgz", - "integrity": "sha512-pVwKvJ4Zk7h+4hwhqOUuMx7Ib02u3gDX3HXPKIShBi9JlYllI0nU6TWLbPT94dt7FSi6mSBhfc2JrHViwqbOdw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", + "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", "cpu": [ "x64" ], @@ -2940,9 +2940,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.3.tgz", - "integrity": "sha512-50ibWdn2RuFFkOEUmo9NCcQbbV9ViQOrUfG48zHBCONciHjaUKtHcYFiCwBVuzD08fzvzkWuuZkd4AqbvKO7UQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", + "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", "cpu": [ "arm64" ], @@ -2956,9 +2956,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.3.tgz", - "integrity": "sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", + "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", "cpu": [ "arm64" ], @@ -2972,9 +2972,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.3.tgz", - "integrity": "sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", + "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", "cpu": [ "x64" ], @@ -2988,9 +2988,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.3.tgz", - "integrity": "sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", + "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", "cpu": [ "x64" ], @@ -3004,9 +3004,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.3.tgz", - "integrity": "sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", + "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", "cpu": [ "arm64" ], @@ -3020,9 +3020,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.3.tgz", - "integrity": "sha512-gHYS9tc+G2W0ZC8rBL+H6RdtXIyk40uLiaos0yj5US85FNhbFEndMA2nW3z47nzOWiSvXTZ5kBClc3rD0zJg0w==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", + "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", "cpu": [ "x64" ], @@ -15324,12 +15324,12 @@ "license": "Apache-2.0" }, "node_modules/next": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.3.tgz", - "integrity": "sha512-x6eDkZxk2rPpu46E1ZVUWIBhYCLszmUY6fvHBFcbzJ9dD+qRX6vcHusaqqDlnY+VngKzKbAiG2iRCkPbmi8f7w==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", + "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", "license": "MIT", "dependencies": { - "@next/env": "15.2.3", + "@next/env": "15.2.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", @@ -15344,14 +15344,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.2.3", - "@next/swc-darwin-x64": "15.2.3", - "@next/swc-linux-arm64-gnu": "15.2.3", - "@next/swc-linux-arm64-musl": "15.2.3", - "@next/swc-linux-x64-gnu": "15.2.3", - "@next/swc-linux-x64-musl": "15.2.3", - "@next/swc-win32-arm64-msvc": "15.2.3", - "@next/swc-win32-x64-msvc": "15.2.3", + "@next/swc-darwin-arm64": "15.2.4", + "@next/swc-darwin-x64": "15.2.4", + "@next/swc-linux-arm64-gnu": "15.2.4", + "@next/swc-linux-arm64-musl": "15.2.4", + "@next/swc-linux-x64-gnu": "15.2.4", + "@next/swc-linux-x64-musl": "15.2.4", + "@next/swc-win32-arm64-msvc": "15.2.4", + "@next/swc-win32-x64-msvc": "15.2.4", "sharp": "^0.33.5" }, "peerDependencies": { diff --git a/package.json b/package.json index fb4ae0a..af68125 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "loading-spinner": "^1.2.1", "lucide-react": "^0.475.0", "neo4j-driver": "^5.28.1", - "next": "^15.2.3", + "next": "^15.2.4", "nodemailer": "^6.10.0", "particles.js": "^2.0.0", "pino-pretty": "^13.0.0",