diff --git a/README.md b/README.md index 28024fe..99e8395 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ NEXTAUTH_URL=https://cryptopath.vercel.app/ NEXTAUTH_SECRET=your-secret-key NEXT_PUBLIC_INFURA_KEY=your-infura-key NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your-walletconnect-projectid +NEXT_PUBLIC_URL=http://localhost:3000 +ALCHEMY_API_KEY=your-alchemy-key +REACT_APP_DAPPRADAR_API_KEY=your-DAPPRADAR-key +COINMARKETCAP_API_KEY=your-COINMARKETCAP-key ``` ```bash # Start the development server @@ -57,9 +61,9 @@ CryptoPath is a blockchain transaction visualization system that simplifies bloc - Leverage a graph database (currently using static demo data) for efficient data storage and retrieval. ## Team Members -- **Le Nguyen Dang Duy** (105028557) - Frontend Lead / Graph Visualization -- **Phan Cong Hung** (104995595) - Backend & Data Integration Lead -- **Nguyen Minh Duy** (104974743) - Full-Stack Developer / UI & UX +- **Le Nguyen Dang Duy** (105028557) - **Frontend Developer / Graph Visualization** +- **Nguyen Minh Duy** (104974743) - **Team Leader / Full-Stack Developer / Product Experience Architect** +- **Phan Cong Hung** (104995595) - **Backend & Frontend Developer / API Integration** ## Project Structure ### Frontend diff --git a/app/NFT/collection/[collectionId]/page.tsx b/app/NFT/collection/[collectionId]/page.tsx new file mode 100644 index 0000000..d6b1ece --- /dev/null +++ b/app/NFT/collection/[collectionId]/page.tsx @@ -0,0 +1,698 @@ + +'use client'; +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import Image from 'next/image'; +import { + fetchCollectionInfo, + fetchCollectionNFTs, +} from '@/lib/api/alchemyNFTApi'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardFooter } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + ArrowLeft, + Info, + ExternalLink, + Copy, + CheckCircle, + Grid, + List, + Search +} from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination'; +import ParticlesBackground from '@/components/ParticlesBackground'; + +interface NFT { + id: string; + tokenId: string; + name: string; + description: string; + imageUrl: string; + attributes: Array<{ + trait_type: string; + value: string; + }>; +} + +export default function CollectionDetailsPage() { + const params = useParams(); + const router = useRouter(); + const { toast } = useToast(); + const collectionId = params?.collectionId as string; + + const [collection, setCollection] = useState(null); + const [nfts, setNfts] = useState([]); + const [loading, setLoading] = useState(true); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [searchQuery, setSearchQuery] = useState(''); + const [network, setNetwork] = useState('0x1'); // Default to Ethereum Mainnet + const [copied, setCopied] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [pageSize] = useState(12); + const [sortBy, setSortBy] = useState('tokenId'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [selectedAttributes, setSelectedAttributes] = useState< + Record + >({}); + const [attributeFilters, setAttributeFilters] = useState< + Record + >({}); + + // Check network + useEffect(() => { + const checkNetwork = async () => { + if (window.ethereum) { + try { + const chainId = await window.ethereum.request({ + method: 'eth_chainId', + }); + if (chainId === '0x1') { + setNetwork('0x1'); + } else if (chainId === '0xaa36a7') { + setNetwork('0xaa36a7'); + } + } catch (error) { + console.error('Error checking network:', error); + } + } + }; + checkNetwork(); + }, []); + + // Load collection data + useEffect(() => { + const loadCollectionData = async () => { + if (!collectionId) return; + + setLoading(true); + try { + const metadata = await fetchCollectionInfo(collectionId, network); + setCollection(metadata); + + const nftData = await fetchCollectionNFTs( + collectionId, + network, + currentPage, + pageSize, + sortBy, + sortDir, + searchQuery, + selectedAttributes + ); + + setNfts(nftData.nfts); + setTotalPages(Math.ceil(nftData.totalCount / pageSize)); + + // Extract attributes for filtering + const attributeMap: Record = {}; + nftData.nfts.forEach((nft: NFT) => { + if (nft.attributes) { + nft.attributes.forEach((attr) => { + if (!attributeMap[attr.trait_type]) { + attributeMap[attr.trait_type] = []; + } + if (!attributeMap[attr.trait_type].includes(attr.value)) { + attributeMap[attr.trait_type].push(attr.value); + } + }); + } + }); + setAttributeFilters(attributeMap); + } catch (error) { + console.error('Error loading collection data:', error); + toast({ + title: 'Error', + description: 'Failed to load collection data. Please try again.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + loadCollectionData(); + }, [ + collectionId, + network, + currentPage, + pageSize, + sortBy, + sortDir, + searchQuery, + selectedAttributes, + toast + ]); + + const handleCopyAddress = () => { + if (!collectionId) return; + navigator.clipboard.writeText(collectionId); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + toast({ + title: 'Address copied', + description: 'The collection address has been copied to your clipboard.', + }); + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setCurrentPage(1); // Reset to first page when searching + }; + + const handleSortChange = (value: string) => { + const [field, direction] = value.split('-'); + setSortBy(field); + setSortDir(direction as 'asc' | 'desc'); + setCurrentPage(1); + }; + + const handleAttributeFilter = (traitType: string, value: string) => { + setSelectedAttributes((prev) => { + const newFilters = { ...prev }; + if (!newFilters[traitType]) { + newFilters[traitType] = []; + } + + if (newFilters[traitType].includes(value)) { + newFilters[traitType] = newFilters[traitType].filter( + (v) => v !== value + ); + } else { + newFilters[traitType].push(value); + } + + if (newFilters[traitType].length === 0) { + delete newFilters[traitType]; + } + + return newFilters; + }); + setCurrentPage(1); + }; + + const clearFilters = () => { + setSelectedAttributes({}); + setSearchQuery(''); + setCurrentPage(1); + }; + + const isAttributeSelected = (traitType: string, value: string) => { + return selectedAttributes[traitType]?.includes(value) || false; + }; + + const formatAddress = (address: string) => { + if (!address) return ''; + return `${address.substring(0, 6)}...${address.substring( + address.length - 4 + )}`; + }; + + const getEtherscanLink = (address: string) => { + const baseUrl = + network === '0x1' + ? 'https://etherscan.io' + : 'https://sepolia.etherscan.io'; + return `${baseUrl}/address/${address}`; + }; + + const renderSkeleton = () => ( +
+
+ +
+ + + +
+
+
+ {[...Array(8)].map((_, i) => ( + + ))} +
+
+ ); + + const renderNFTCard = (nft: NFT) => { + let imageUrl = nft.imageUrl; + if (imageUrl && imageUrl.startsWith('ipfs://')) { + imageUrl = `https://ipfs.io/ipfs/${imageUrl.slice(7)}`; + } + + return ( + +
+ {imageUrl ? ( + {nft.name { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + target.parentElement!.classList.add('bg-gray-800', 'flex', 'items-center', 'justify-center'); + const icon = document.createElement('div'); + icon.innerHTML = ''; + target.parentElement!.appendChild(icon); + }} + /> + ) : ( +
+ +
+ )} +
+ +

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

+

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

+
+ +
+ {nft.attributes?.slice(0, 3).map((attr, i) => ( + + {attr.trait_type}: {attr.value} + + ))} + {nft.attributes?.length > 3 && ( + + +{nft.attributes.length - 3} more + + )} +
+
+
+ ); + }; + + const renderNFTList = (nft: NFT) => { + let imageUrl = nft.imageUrl; + if (imageUrl && imageUrl.startsWith('ipfs://')) { + imageUrl = `https://ipfs.io/ipfs/${imageUrl.slice(7)}`; + } + + return ( + +
+ {imageUrl ? ( + {nft.name { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + target.parentElement!.classList.add('bg-gray-800', 'flex', 'items-center', 'justify-center'); + const icon = document.createElement('div'); + icon.innerHTML = ''; + target.parentElement!.appendChild(icon); + }} + /> + ) : ( +
+ +
+ )} +
+
+
+
+

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

+

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

+
+
+ {nft.attributes?.slice(0, 3).map((attr, i) => ( + + {attr.trait_type}: {attr.value} + + ))} + {nft.attributes?.length > 3 && ( + + +{nft.attributes.length - 3} more + + )} +
+
+
+
+ ); + }; + + return ( +
+ + +
+ + + {loading ? ( + renderSkeleton() + ) : collection ? ( + <> +
+ {collection.imageUrl ? ( +
+ {collection.name} { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + target.parentElement!.classList.add('bg-gray-800', 'flex', 'items-center', 'justify-center'); + const icon = document.createElement('div'); + icon.innerHTML = ''; + target.parentElement!.appendChild(icon); + }} + /> +
+ ) : ( +
+ +
+ )} + +
+
+

{collection.name}

+ + {network === '0x1' ? 'Ethereum' : 'Sepolia'} + +
+ +
+
+ +
+ + + Etherscan + +
+ +

{collection.description}

+ +
+ {collection.totalSupply && ( + + Total Items: {collection.totalSupply} + + )} + {collection.symbol && ( + + Symbol: {collection.symbol} + + )} +
+
+
+ +
+
+
+

Filters

+ +
+ +
+ {Object.entries(attributeFilters).map(([traitType, values]) => ( +
+

{traitType}

+ {values.sort().map((value) => ( +
+ +
+ ))} +
+ ))} +
+
+ +
+
+
+ + +
+ +
+ + +
+ + +
+
+
+ + {nfts.length === 0 ? ( +
+

+ No NFTs found for this collection. +

+ {(searchQuery || + Object.keys(selectedAttributes).length > 0) && ( + + )} +
+ ) : ( + <> +
+ {nfts.map((nft) => + viewMode === 'grid' + ? renderNFTCard(nft) + : renderNFTList(nft) + )} +
+ + {totalPages > 1 && ( + + + + { + e.preventDefault(); + if (currentPage > 1) + setCurrentPage(currentPage - 1); + }} + className={ + currentPage === 1 + ? 'pointer-events-none opacity-50' + : '' + } + /> + + + {[...Array(totalPages)].map((_, i) => { + const pageNumber = i + 1; + // Show first page, last page, and pages around current page + if ( + pageNumber === 1 || + pageNumber === totalPages || + (pageNumber >= currentPage - 1 && + pageNumber <= currentPage + 1) + ) { + return ( + + { + e.preventDefault(); + setCurrentPage(pageNumber); + }} + isActive={pageNumber === currentPage} + > + {pageNumber} + + + ); + } + + // Show ellipsis for gaps + if ( + (pageNumber === 2 && currentPage > 3) || + (pageNumber === totalPages - 1 && + currentPage < totalPages - 2) + ) { + return ( + + ... + + ); + } + + return null; + })} + + + { + e.preventDefault(); + if (currentPage < totalPages) + setCurrentPage(currentPage + 1); + }} + className={ + currentPage === totalPages + ? 'pointer-events-none opacity-50' + : '' + } + /> + + + + )} + + )} +
+
+ + ) : ( +
+ +

Collection not found

+

+ The collection you're looking for doesn't exist or couldn't be loaded. +

+ + + +
+ )} +
+
+ ); +} diff --git a/app/NFT/collection/page.tsx b/app/NFT/collection/page.tsx new file mode 100644 index 0000000..b4c84a5 --- /dev/null +++ b/app/NFT/collection/page.tsx @@ -0,0 +1,361 @@ + +'use client'; +import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Image from 'next/image'; +import Link from 'next/link'; +import { + fetchPopularCollections, + fetchUserNFTs +} from '@/lib/api/alchemyNFTApi'; +import { useWallet } from '@/components/Faucet/walletcontext'; +import ParticlesBackground from '@/components/ParticlesBackground'; +import { Card, CardContent, CardFooter } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Search, + Wallet, + TrendingUp, + ArrowLeft, + ExternalLink, + Grid, + Info +} from 'lucide-react'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useToast } from '@/hooks/use-toast'; + + +export default function NFTCollectionPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { account, connectWallet } = useWallet(); + const { toast } = useToast(); + + const [collections, setCollections] = useState([]); + const [userNFTs, setUserNFTs] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [activeTab, setActiveTab] = useState('popular'); + const [chainId, setChainId] = useState('0x1'); // Default to Ethereum Mainnet + + const supportedChains = [ + { id: '0x1', name: 'Ethereum', icon: '/icons/eth.svg' }, + { id: '0x89', name: 'Polygon', icon: '/icons/matic.svg' }, + { id: '0xa', name: 'Optimism', icon: '/icons/op.svg' }, + { id: '0xa4b1', name: 'Arbitrum', icon: '/icons/arb.svg' }, + { id: '0x38', name: 'BSC', icon: '/icons/bnb.svg' }, + ]; + + // Check network and load initial data + useEffect(() => { + const checkNetwork = async () => { + if (window.ethereum) { + try { + const chainId = await window.ethereum.request({ + method: 'eth_chainId', + }); + if (Object.keys(supportedChains).includes(chainId)) { + setChainId(chainId); + } + } catch (error) { + console.error('Error checking network:', error); + } + } + }; + + checkNetwork(); + loadPopularCollections(); + }, []); + + // Load user NFTs when account changes + useEffect(() => { + if (account && activeTab === 'my-nfts') { + loadUserNFTs(); + } + }, [account, activeTab, chainId]); + + const loadPopularCollections = async () => { + setLoading(true); + try { + const data = await fetchPopularCollections(chainId); + setCollections(data); + } catch (error) { + console.error('Error loading collections:', error); + toast({ + title: 'Error', + description: 'Failed to load collections', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const loadUserNFTs = async () => { + if (!account) return; + + setLoading(true); + try { + const response = await fetchUserNFTs(account, chainId); + + // Group NFTs by collection + const grouped = response.ownedNfts.reduce((acc, nft) => { + const contract = nft.contract.address; + if (!acc[contract]) { + acc[contract] = { + contractAddress: contract, + name: nft.contract.name || 'Unknown Collection', + symbol: nft.contract.symbol || '', + count: 0, + imageUrl: nft.media[0]?.gateway || '', + }; + } + acc[contract].count++; + return acc; + }, {} as Record); + + setUserNFTs(Object.values(grouped)); + } catch (error) { + console.error('Error loading user NFTs:', error); + toast({ + title: 'Error', + description: 'Failed to load your NFTs', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (!searchQuery) return; + + const isAddress = /^0x[a-fA-F0-9]{40}$/.test(searchQuery); + + if (isAddress) { + router.push(`/NFT/collection/${searchQuery}`); + } else { + toast({ + title: 'Invalid input', + description: 'Please enter a valid contract address', + variant: 'destructive', + }); + } + }; + + const handleTabChange = (value: string) => { + setActiveTab(value); + if (value === 'my-nfts' && account) { + loadUserNFTs(); + } else if (value === 'popular') { + loadPopularCollections(); + } + }; + + const handleConnectWallet = (e: React.MouseEvent) => { + e.preventDefault(); + connectWallet(); + }; + + const renderCollectionCard = (collection: any) => ( + + +
+ {collection.imageUrl ? ( + {collection.name} { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + target.parentElement!.classList.add('bg-gray-800', 'flex', 'items-center', 'justify-center'); + const icon = document.createElement('div'); + icon.innerHTML = ''; + target.parentElement!.appendChild(icon); + }} + /> + ) : ( +
+ +
+ )} +
+ + + +

{collection.name}

+ + {collection.floorPrice && ( +

+ Floor: {collection.floorPrice} ETH +

+ )} + {collection.count && ( +

+ Owned: {collection.count} +

+ )} +
+ +
+ + {collection.totalSupply ? `${collection.totalSupply} items` : 'View Collection'} + +
+ e.stopPropagation()} + > + + +
+
+ ); + + return ( +
+ + +
+
+
+
+ + + +

NFT Collections

+
+ +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 bg-gray-800/50 border-gray-700" + /> + + + + {!account && ( + + )} +
+
+ +
+ + + + Popular Collections + + + My NFTs + + + + + {loading ? ( +
+ {[...Array(8)].map((_, i) => ( + + + + + + + + + + + ))} +
+ ) : ( + <> + {activeTab === 'popular' && ( +
+ {collections.map((collection) => ( +
+ {renderCollectionCard(collection)} +
+ ))} +
+ )} + + {activeTab === 'my-nfts' && ( + <> + {!account ? ( +
+ +

Connect your wallet to view your NFTs

+

+ Connect your wallet to view all your NFT collections across different blockchains. +

+ +
+ ) : userNFTs.length === 0 ? ( +
+ +

No NFTs found

+

+ We couldn't find any NFTs in your wallet. If you believe this is an error, try switching networks. +

+
+ {supportedChains.map((chain) => ( + + ))} +
+
+ ) : ( +
+ {userNFTs.map((collection) => ( +
+ {renderCollectionCard(collection)} +
+ ))} +
+ )} + + )} + + )} +
+
+
+
+ ); +} diff --git a/app/NFT/layout.tsx b/app/NFT/layout.tsx new file mode 100644 index 0000000..34f8dbd --- /dev/null +++ b/app/NFT/layout.tsx @@ -0,0 +1,162 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { LayoutGrid, Layers, Bookmark, Activity, ChevronRight, Info, Wallet } from 'lucide-react'; +import { Button } from '@/components/ui/button'; // Add Button import +import { useWallet } from '@/components/Faucet/walletcontext'; // Add wallet context import +import ParticlesBackground from "@/components/ParticlesBackground"; + +export default function NFTLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const [mounted, setMounted] = useState(false); + const { account, connectWallet } = useWallet(); // Add wallet context hooks + + // Determine active section based on URL + const isMarketplace = pathname === '/NFT'; + const isCollections = pathname.includes('/NFT/collection'); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) return null; + + return ( +
+ +
+ {/* Navigation Tabs with Wallet Button */} +
+
+ + + + + + +
+ + {/* Connect Wallet Button */} + +
+ + {/* Breadcrumb Navigation */} + + + {/* Info Banner */} + {isMarketplace && ( +
+ +
+

About PATH Marketplace

+

+ This marketplace is exclusive to CryptoPath ecosystem NFTs. Connect your wallet to start trading PATH NFTs. + You'll need PATH tokens for transactions, which you can get from our Faucet. +

+
+
+ )} + + {/* Page Title */} +

+ {isMarketplace ? ( + <>PATH NFT Marketplace + ) : ( + <>NFT Collections Explorer + )} +

+ + {/* Main Content */} +
{children}
+
+
+ ); +} diff --git a/app/NFT/page.tsx b/app/NFT/page.tsx index fea0126..4221c81 100644 --- a/app/NFT/page.tsx +++ b/app/NFT/page.tsx @@ -8,7 +8,9 @@ import MintForm from '@/components/NFT/MintForm'; import WhitelistForm from '@/components/NFT/WhitelistForm'; import { useWallet } from '@/components/Faucet/walletcontext'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import ParticlesBackground from '@/components/ParticlesBackground'; +import NFTMarketStats from '@/components/NFT/NFTMarketStats'; +import PriceChart from '@/components/NFT/PriceChart'; +import { toast } from '@/hooks/use-toast'; // Contract addresses const NFT_CONTRACT_ADDRESS = "0xdf5d4038723f6605A3eCd7776FFe25f3b1Be39a0"; @@ -53,6 +55,13 @@ interface NFTData { listings: any[]; } +interface NFTMetadata { + name: string; + image: string; + description?: string; + [key: string]: any; +} + export default function NFTMarketplace() { const { account, connectWallet } = useWallet(); const [activeTab, setActiveTab] = useState<'market' | 'owned' | 'listings' | 'mint' | 'whitelist'>('market'); @@ -67,6 +76,16 @@ export default function NFTMarketplace() { listings: [] }); const [isInitialLoad, setIsInitialLoad] = useState(true); + + // Market statistics + const [marketStats, setMarketStats] = useState({ + totalVolume: '12,450.35', + dailyVolume: '1,245.62', + avgPrice: '125.75', + listedCount: 48, + soldCount: 152, + priceChange: 8.5 + }); const isOwner = useMemo(() => account?.toLowerCase() === ownerAddress.toLowerCase(), @@ -118,18 +137,49 @@ export default function NFTMarketplace() { }, [account, checkWhitelistStatus]); // Fetch PATH balance - const fetchPathBalance = useCallback(async (account: string) => { + const fetchPathBalance = useCallback(async (address: string) => { try { const provider = getProvider(); const tokenContract = new ethers.Contract(PATH_TOKEN_ADDRESS, TOKEN_ABI, provider); - const balance = await tokenContract.balanceOf(account); + const balance = await tokenContract.balanceOf(address); setPathBalance(parseFloat(ethers.utils.formatUnits(balance, 18)).toFixed(4)); } catch (error) { console.error("Error fetching PATH balance:", error); } }, []); - // Fetch NFT data + // Fetch metadata with timeout and retry + const fetchMetadata = async (uri: string, retries = 3): Promise => { + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('Metadata fetch timeout')), 5000) + ); + + for (let i = 0; i < retries; i++) { + try { + const response = await Promise.race([ + fetch(uri), + timeout + ]) as Response; + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + if (!data.name || !data.image) { + throw new Error('Invalid metadata format'); + } + + return data as NFTMetadata; + } catch (error) { + if (i === retries - 1) throw error; + await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); + } + } + throw new Error('Failed to fetch metadata after retries'); + }; + + // Fetch NFT data with improved error handling const fetchNFTs = useCallback(async () => { if (!account) return; @@ -139,8 +189,8 @@ export default function NFTMarketplace() { const listedIds = await contract.getAllListings().catch(() => []); - // Market NFTs - const marketNFTs = await Promise.all( + // Market NFTs with improved error handling + const marketNFTs = await Promise.allSettled( listedIds.map(async (id: ethers.BigNumber) => { try { const [uri, listing, owner] = await Promise.all([ @@ -148,7 +198,9 @@ export default function NFTMarketplace() { contract.listings(id), contract.ownerOf(id).catch(() => '0x0') ]); - const metadata = await fetch(uri.toString()).then(res => res.json()); + + const metadata = await fetchMetadata(uri); + return { id: id.toString(), ...metadata, @@ -164,46 +216,67 @@ export default function NFTMarketplace() { }) ); - // Owned NFTs + // Owned NFTs with improved pagination const totalSupply = await contract.totalSupply().catch(() => ethers.BigNumber.from(0)); + const pageSize = 20; // Process in smaller chunks const allIds = Array.from({ length: totalSupply.toNumber() }, (_, i) => i); - - const ownedNFTs = await Promise.all( - allIds.map(async (id) => { - try { - const [owner, listing] = await Promise.all([ - contract.ownerOf(id).catch(() => '0x0'), - contract.listings(id) - ]); - if (owner.toLowerCase() === account.toLowerCase() && !listing.isListed) { - const uri = await contract.tokenURI(id); - const metadata = await fetch(uri.toString()).then(res => res.json()); - return { - id: id.toString(), - ...metadata, - owner: owner, - isListed: false - }; + const ownedNFTs = []; + + for (let i = 0; i < allIds.length; i += pageSize) { + const pageIds = allIds.slice(i, i + pageSize); + const pageResults = await Promise.allSettled( + pageIds.map(async (id) => { + try { + const [owner, listing] = await Promise.all([ + contract.ownerOf(id).catch(() => '0x0'), + contract.listings(id) + ]); + + if (owner.toLowerCase() === account.toLowerCase() && !listing.isListed) { + const uri = await contract.tokenURI(id); + const metadata = await fetchMetadata(uri); + + return { + id: id.toString(), + ...metadata, + owner: owner, + isListed: false + }; + } + return null; + } catch (error) { + console.error(`Error processing owned NFT ${id}:`, error); + return null; } - return null; - } catch (error) { - return null; - } - }) - ); + }) + ); + + ownedNFTs.push(...pageResults + .filter(result => result.status === 'fulfilled' && result.value !== null) + .map(result => (result as PromiseFulfilledResult).value) + ); + } + + const validMarketNFTs = marketNFTs + .filter(result => result.status === 'fulfilled' && result.value !== null) + .map(result => (result as PromiseFulfilledResult).value); setNftData({ - market: marketNFTs.filter(nft => nft !== null), - owned: ownedNFTs.filter(nft => nft !== null), - listings: marketNFTs.filter(nft => - nft !== null && - nft.isListed && + market: validMarketNFTs, + owned: ownedNFTs, + listings: validMarketNFTs.filter(nft => + nft.isListed && nft.seller?.toLowerCase() === account.toLowerCase() ) }); setIsInitialLoad(false); } catch (error) { console.error("Error fetching NFTs:", error); + toast({ + title: "Error", + description: "Failed to fetch NFTs. Please try again later.", + variant: "destructive" + }); } }, [account]); @@ -259,12 +332,24 @@ export default function NFTMarketplace() { const signer = provider.getSigner(); const contract = new ethers.Contract(NFT_CONTRACT_ADDRESS, NFT_ABI, signer); + // Validate the token URI before minting + await fetchMetadata(tokenURI); + const tx = await contract.mintNFT(recipient, tokenURI); await tx.wait(); await refreshData(); + toast({ + title: "Success", + description: "NFT minted successfully!", + variant: "default" + }); } catch (error) { console.error("Minting failed:", error); - alert(error instanceof Error ? error.message : "Mint failed"); + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Mint failed", + variant: "destructive" + }); } finally { setProcessing(false); } @@ -273,7 +358,11 @@ export default function NFTMarketplace() { // Handle buy NFT const handleBuyNFT = async (tokenId: string, price: string) => { if (!account) { - alert("Please connect wallet!"); + toast({ + title: "Error", + description: "Please connect wallet first!", + variant: "destructive" + }); return; } @@ -298,9 +387,18 @@ export default function NFTMarketplace() { await tx.wait(); await refreshData(); + toast({ + title: "Success", + description: "NFT purchased successfully!", + variant: "default" + }); } catch (error) { console.error("Purchase failed:", error); - alert("Transaction failed! Check console for details."); + toast({ + title: "Error", + description: "Transaction failed! Check console for details.", + variant: "destructive" + }); } finally { setProcessing(false); } @@ -309,7 +407,11 @@ export default function NFTMarketplace() { // Handle list NFT const handleListNFT = async (tokenId: string, price: string) => { if (!account) { - alert("Please connect wallet first!"); + toast({ + title: "Error", + description: "Please connect wallet first!", + variant: "destructive" + }); return; } @@ -331,9 +433,18 @@ export default function NFTMarketplace() { ); await tx.wait(); await refreshData(); + toast({ + title: "Success", + description: "NFT listed successfully!", + variant: "default" + }); } catch (error) { console.error("Listing failed:", error); - alert(error instanceof Error ? error.message : "Unknown error"); + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Unknown error", + variant: "destructive" + }); } finally { setProcessing(false); } @@ -342,7 +453,11 @@ export default function NFTMarketplace() { // Handle unlist NFT const handleUnlistNFT = async (tokenId: string) => { if (!account) { - alert("Please connect wallet first!"); + toast({ + title: "Error", + description: "Please connect wallet first!", + variant: "destructive" + }); return; } @@ -362,13 +477,18 @@ export default function NFTMarketplace() { const tx = await contract.unlistNFT(tokenId); await tx.wait(); await refreshData(); + toast({ + title: "Success", + description: "NFT unlisted successfully!", + variant: "default" + }); } catch (error: unknown) { console.error("Unlisting failed:", error); - alert( - error instanceof Error - ? error.message - : "Unknown error occurred during unlisting" - ); + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Unknown error occurred during unlisting", + variant: "destructive" + }); } finally { setProcessing(false); } @@ -386,164 +506,150 @@ export default function NFTMarketplace() { const tx = await contract.updateWhitelist(address, status); await tx.wait(); - alert('Whitelist updated successfully!'); + toast({ + title: "Success", + description: "Whitelist updated successfully!", + variant: "default" + }); } catch (error) { console.error("Whitelist update failed:", error); - alert(error instanceof Error ? error.message : "Update failed"); + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Update failed", + variant: "destructive" + }); } finally { setProcessing(false); } }; return ( -
- -
- {/* Header section */} -
-

- NFT Marketplace -

-
- {account && ( -
- - {pathBalance} - - PATH -
- )} - -
-
- - - - {!account ? ( -
- Please connect your wallet to view NFTs -
- ) : ( - <> - {isInitialLoad ? ( -
- {[...Array(8)].map((_, i) => ( -
- ))} -
- ) : ( - <> - {activeTab === 'mint' ? ( - - - Mint - - - - - - ) : activeTab === 'whitelist' ? ( - - - Whitelist Management - - - - - - ) : ( - <> -
- {paginatedData.map((nft, index) => ( -
- handleBuyNFT(tokenId, price || '0') - : activeTab === 'owned' - ? (tokenId, price) => handleListNFT(tokenId, price || '0') - : handleUnlistNFT - } - processing={processing} - /> -
- ))} -
- - {totalPages > 1 && ( -
- +
+

Trade exclusive NFTs in the PATH ecosystem using PATH tokens

+
+ + {/* Market Statistics */} + + + {/* Price Chart */} +
+ +
+ + {/* Tabs Navigation */} + + + {!account ? ( +
+ Please connect your wallet to view NFTs +
+ ) : ( + <> + {isInitialLoad ? ( +
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+ ) : ( + <> + {activeTab === 'mint' ? ( + + + Mint + + + + + + ) : activeTab === 'whitelist' ? ( + + + Whitelist Management + + + + + + ) : ( + <> +
+ {paginatedData.map((nft, index) => ( +
+ handleBuyNFT(tokenId, price || '0') + : activeTab === 'owned' + ? (tokenId, price) => handleListNFT(tokenId, price || '0') + : handleUnlistNFT + } + processing={processing} />
- )} - - )} - - )} - - )} - - {processing && ( -
-
-
-

Processing Transaction

-
- {[...Array(3)].map((_, i) => ( -
- ))} -
+ ))} +
+ + {totalPages > 1 && ( +
+ +
+ )} + + )} + + )} + + )} + + {processing && ( +
+
+
+

Processing Transaction

+
+ {[...Array(3)].map((_, i) => ( +
+ ))}
- )} -
+
+ )}
); } \ No newline at end of file diff --git a/app/api/analytics/chain-stats/route.ts b/app/api/analytics/chain-stats/route.ts new file mode 100644 index 0000000..0207696 --- /dev/null +++ b/app/api/analytics/chain-stats/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET() { + try { + // Try to fetch real data from Blockchain.info + const response = await axios.get("https://api.blockchain.info/stats"); + + if (response.data) { + return NextResponse.json({ + data: { + hashRate: response.data.hash_rate, + difficulty: response.data.difficulty, + latestHeight: response.data.n_blocks_total, + unconfirmedTx: response.data.n_tx_unconfirmed, + mempool: response.data.mempool_size, + btcMined: response.data.n_btc_mined, + marketPrice: response.data.market_price_usd, + transactionRate: response.data.n_tx_per_block, + minutesBetweenBlocks: response.data.minutes_between_blocks, + totalFees: response.data.total_fees_btc + }, + timestamp: Date.now() + }); + } else { + throw new Error("Invalid response from Blockchain.info API"); + } + } catch (error) { + console.error("Failed to fetch blockchain stats:", error); + + // Return simulated data if the API call fails + return NextResponse.json({ + data: { + hashRate: 180000000000000, // 180 EH/s + difficulty: 53950000000000, + latestHeight: 820000, + unconfirmedTx: 5000, + mempool: 8500, + btcMined: 19250000, + marketPrice: 60000, + transactionRate: 2500, + minutesBetweenBlocks: 9.75, + totalFees: 1.25 + }, + timestamp: Date.now(), + simulated: true + }); + } +} \ No newline at end of file diff --git a/app/api/analytics/defi-tvl/route.ts b/app/api/analytics/defi-tvl/route.ts new file mode 100644 index 0000000..8726ecb --- /dev/null +++ b/app/api/analytics/defi-tvl/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const protocol = url.searchParams.get('protocol') || 'all'; + + // Choose endpoint based on whether we're getting global data or protocol-specific + const endpoint = protocol === 'all' + ? 'https://api.llama.fi/charts' + : `https://api.llama.fi/protocol/${protocol}`; + + const response = await axios.get(endpoint); + + if (!response.data) { + throw new Error("Invalid response from DefiLlama API"); + } + + // For global data, we get the TVL history directly + if (protocol === 'all') { + // Get the last 30 days of data + const data = response.data.slice(-30).map((item: any) => ({ + date: new Date(item.date * 1000).toLocaleDateString(), + tvl: item.totalLiquidityUSD + })); + + return NextResponse.json({ + data, + totalTvl: data[data.length - 1]?.tvl || 0, + timestamp: Date.now() + }); + } + // For protocol data, we need to extract the TVL from the protocol object + else { + const tvlData = response.data.tvl.slice(-30).map((item: any) => ({ + date: new Date(item.date * 1000).toLocaleDateString(), + tvl: item.totalLiquidityUSD + })); + + return NextResponse.json({ + name: response.data.name, + symbol: response.data.symbol, + data: tvlData, + totalTvl: tvlData[tvlData.length - 1]?.tvl || 0, + chains: response.data.chains, + timestamp: Date.now() + }); + } + } catch (error: any) { + console.error("Error fetching DeFi TVL data:", error.message); + + // Return simulated data if the API call fails + const dates = []; + const now = new Date(); + let simulatedTvl = 150000000000; // $150B starting point + + for (let i = 30; i > 0; i--) { + const date = new Date(now); + date.setDate(now.getDate() - i); + + // Random daily change between -3% and +3% + const dailyChange = simulatedTvl * (Math.random() * 0.06 - 0.03); + simulatedTvl += dailyChange; + + dates.push({ + date: date.toLocaleDateString(), + tvl: simulatedTvl + }); + } + + return NextResponse.json({ + data: dates, + totalTvl: simulatedTvl, + timestamp: Date.now(), + simulated: true + }); + } +} diff --git a/app/api/analytics/exchange-volumes/route.ts b/app/api/analytics/exchange-volumes/route.ts new file mode 100644 index 0000000..5169d04 --- /dev/null +++ b/app/api/analytics/exchange-volumes/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server'; +import axios from "axios"; + +export async function GET() { + try { + // Fetch real exchange data from CoinGecko's free API + const response = await axios.get( + "https://api.coingecko.com/api/v3/exchanges?per_page=5&page=1" + ); + + if (!response.data || !Array.isArray(response.data)) { + throw new Error("Invalid response from CoinGecko API"); + } + + // Map to the format our frontend expects + // Assign colors to make visualization consistent + const colors = ['#F0B90B', '#1652F0', '#1A1B1F', '#1F94E0', '#26A17B']; + + const exchangeData = { + data: response.data.map((exchange, index) => ({ + name: exchange.name, + volume: exchange.trade_volume_24h_btc * + (response.data[0].trade_volume_24h_btc > 10000 ? 1 : 60000), // Convert to USD if needed + color: colors[index % colors.length] + })), + timestamp: Date.now() + }; + + // Calculate total volume + exchangeData.data.sort((a, b) => b.volume - a.volume); + const totalVolume = exchangeData.data.reduce((sum, ex) => sum + ex.volume, 0); + + return NextResponse.json({ + ...exchangeData, + totalVolume + }, { status: 200 }); + } catch (error) { + console.error('Error in exchange volumes API:', error); + + // Fallback to simulated data if the API call fails + const exchangeData = { + data: [ + { name: 'Binance', volume: 25000000000 + (Math.random() * 5000000000), color: '#F0B90B' }, + { name: 'Coinbase', volume: 12000000000 + (Math.random() * 2000000000), color: '#1652F0' }, + { name: 'OKX', volume: 8000000000 + (Math.random() * 1000000000), color: '#1A1B1F' }, + { name: 'Huobi', volume: 5000000000 + (Math.random() * 1000000000), color: '#1F94E0' }, + { name: 'KuCoin', volume: 3000000000 + (Math.random() * 800000000), color: '#26A17B' }, + ], + timestamp: Date.now(), + simulated: true + }; + + const totalVolume = exchangeData.data.reduce((sum, ex) => sum + ex.volume, 0); + + return NextResponse.json({ + ...exchangeData, + totalVolume + }, { status: 200 }); + } +} diff --git a/app/api/analytics/fear-greed-index/route.ts b/app/api/analytics/fear-greed-index/route.ts new file mode 100644 index 0000000..27e31ce --- /dev/null +++ b/app/api/analytics/fear-greed-index/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET() { + try { + // Alternative Fear & Greed Index API (free, no API key required) + const response = await axios.get("https://api.alternative.me/fng/"); + + if (response.data && response.data.data && response.data.data[0]) { + const data = response.data.data[0]; + return NextResponse.json({ + value: parseInt(data.value), + valueText: data.value_classification, + timestamp: Date.now() + }); + } else { + throw new Error("Invalid response from Alternative.me API"); + } + } catch (error: any) { + console.error("Error fetching Fear & Greed Index:", error.message); + + // Return simulated data if the API call fails + const simulatedValue = Math.floor(Math.random() * 20) + 15; // Random value between 15-35 + let valueText = "Fear"; + + if (simulatedValue <= 20) valueText = "Extreme Fear"; + else if (simulatedValue <= 40) valueText = "Fear"; + else if (simulatedValue <= 60) valueText = "Neutral"; + else if (simulatedValue <= 80) valueText = "Greed"; + else valueText = "Extreme Greed"; + + return NextResponse.json({ + value: simulatedValue, + valueText, + timestamp: Date.now(), + simulated: true + }); + } +} \ No newline at end of file diff --git a/app/api/analytics/gas-prices/route.ts b/app/api/analytics/gas-prices/route.ts new file mode 100644 index 0000000..b70c0e8 --- /dev/null +++ b/app/api/analytics/gas-prices/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET() { + try { + // Try ETH Gas Station API first (which is free) + const response = await axios.get('https://ethgasstation.info/api/ethgasAPI.json'); + + if (response.data) { + // ETH Gas Station returns values in tenths of Gwei, so divide by 10 + return NextResponse.json({ + slow: Math.round(response.data.safeLow / 10), + average: Math.round(response.data.average / 10), + fast: Math.round(response.data.fast / 10), + fastest: Math.round(response.data.fastest / 10), + baseFee: Math.round(response.data.avgBaseFee / 10), + timestamp: Date.now() + }); + } else { + throw new Error("Invalid response from ETH Gas Station API"); + } + } catch (error) { + try { + // Fallback to Etherscan API if available + const apiKey = process.env.ETHERSCAN_API_KEY; + + if (!apiKey) { + throw new Error("Etherscan API key is not configured"); + } + + const etherscanResponse = await axios.get( + `https://api.etherscan.io/api?module=gastracker&action=gasoracle&apikey=${apiKey}` + ); + + if (etherscanResponse.data && etherscanResponse.data.status === "1" && etherscanResponse.data.result) { + const { SafeGasPrice, ProposeGasPrice, FastGasPrice, suggestBaseFee } = etherscanResponse.data.result; + + return NextResponse.json({ + slow: parseInt(SafeGasPrice), + average: parseInt(ProposeGasPrice), + fast: parseInt(FastGasPrice), + baseFee: parseFloat(suggestBaseFee), + timestamp: Date.now() + }); + } else { + throw new Error("Invalid response from Etherscan API"); + } + } catch (fallbackError) { + console.error("Error fetching gas prices:", fallbackError); + + // Return simulated data if both API calls fail + return NextResponse.json({ + slow: Math.floor(Math.random() * 20) + 10, + average: Math.floor(Math.random() * 30) + 25, + fast: Math.floor(Math.random() * 50) + 50, + baseFee: (Math.random() * 10 + 5).toFixed(2), + timestamp: Date.now(), + simulated: true + }); + } + } +} diff --git a/app/api/analytics/market-sentiment/route.ts b/app/api/analytics/market-sentiment/route.ts new file mode 100644 index 0000000..b453681 --- /dev/null +++ b/app/api/analytics/market-sentiment/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import axios from 'axios'; + +export async function GET() { + try { + // Try to fetch real Fear & Greed Index data + // This is an unofficial API endpoint that provides the data + const response = await axios.get('https://api.alternative.me/fng/'); + + if (response.data && response.data.data && response.data.data[0]) { + const fngData = response.data.data[0]; + + // Map the fear & greed value to our format + // F&G index is 0-100, where 0 is extreme fear and 100 is extreme greed + const sentimentData = { + score: parseInt(fngData.value), + // Add some variance for social and news, but keep them related to the main score + socialMediaScore: Math.min(Math.max(parseInt(fngData.value) + Math.floor(Math.random() * 20) - 10, 0), 100), + newsScore: Math.min(Math.max(parseInt(fngData.value) + Math.floor(Math.random() * 20) - 10, 0), 100), + classification: fngData.value_classification, + redditMentions: Math.floor(Math.random() * 30000) + 10000, // Still simulated + twitterMentions: Math.floor(Math.random() * 100000) + 50000, // Still simulated + timestamp: Date.now() + }; + + return NextResponse.json(sentimentData, { status: 200 }); + } else { + throw new Error("Invalid response from Fear & Greed API"); + } + } catch (error) { + console.error('Error in market sentiment API:', error); + + // Return simulated data if the API call fails + const baseScore = Math.random() < 0.6 ? + Math.floor(Math.random() * 20) + 45 : + Math.floor(Math.random() * 40) + 30; + + const socialMediaVariance = Math.floor(Math.random() * 20) - 10; + const newsVariance = Math.floor(Math.random() * 20) - 10; + + return NextResponse.json({ + score: baseScore, + socialMediaScore: Math.min(Math.max(baseScore + socialMediaVariance, 0), 100), + newsScore: Math.min(Math.max(baseScore + newsVariance, 0), 100), + redditMentions: Math.floor(Math.random() * 30000) + 10000, + twitterMentions: Math.floor(Math.random() * 100000) + 50000, + timestamp: Date.now(), + simulated: true + }, { status: 200 }); + } +} diff --git a/app/api/analytics/nft-stats/route.ts b/app/api/analytics/nft-stats/route.ts new file mode 100644 index 0000000..5e9e1d8 --- /dev/null +++ b/app/api/analytics/nft-stats/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET() { + try { + const apiKey = process.env.MORALIS_API_KEY; + + if (!apiKey) { + throw new Error("Moralis API key is not configured"); + } + + // Get NFT collections stats using Moralis API + const response = await axios.get( + "https://deep-index.moralis.io/api/v2/nft/collections/stats", + { + headers: { + "X-API-Key": apiKey, + }, + params: { + limit: 5, + chain: "eth" + } + } + ); + + if (response.data && response.data.result) { + return NextResponse.json({ + collections: response.data.result, + timestamp: Date.now() + }); + } else { + throw new Error("Invalid response from Moralis API"); + } + } catch (error: any) { + console.error("Error fetching NFT stats:", error.message); + + // Return simulated data if API call fails + return NextResponse.json({ + collections: [ + { + name: "Bored Ape Yacht Club", + symbol: "BAYC", + floorPrice: 72.45, + volume24h: 456.32, + totalVolume: 890745.23, + owners: 6213 + }, + { + name: "CryptoPunks", + symbol: "PUNK", + floorPrice: 64.21, + volume24h: 387.16, + totalVolume: 754221.54, + owners: 3311 + }, + { + name: "Azuki", + symbol: "AZUKI", + floorPrice: 14.62, + volume24h: 165.84, + totalVolume: 246731.12, + owners: 5120 + }, + { + name: "Doodles", + symbol: "DOODLE", + floorPrice: 8.75, + volume24h: 94.36, + totalVolume: 124563.74, + owners: 4892 + }, + { + name: "Moonbirds", + symbol: "MOONBIRD", + floorPrice: 6.32, + volume24h: 57.91, + totalVolume: 89471.65, + owners: 7854 + } + ], + timestamp: Date.now(), + simulated: true + }); + } +} diff --git a/app/api/analytics/trending-coins/route.ts b/app/api/analytics/trending-coins/route.ts new file mode 100644 index 0000000..3eae21e --- /dev/null +++ b/app/api/analytics/trending-coins/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET() { + try { + // CoinGecko API is free and doesn't require an API key + const response = await axios.get("https://api.coingecko.com/api/v3/search/trending"); + + if (response.data && response.data.coins) { + const trendingCoins = response.data.coins.slice(0, 7).map((item: any) => ({ + id: item.item.id, + name: item.item.name, + symbol: item.item.symbol, + thumb: item.item.thumb, + price_btc: item.item.price_btc, + market_cap_rank: item.item.market_cap_rank, + score: item.item.score + })); + + return NextResponse.json({ + coins: trendingCoins, + timestamp: Date.now() + }); + } else { + throw new Error("Invalid response from CoinGecko API"); + } + } catch (error: any) { + console.error("Error fetching trending coins:", error.message); + + // Return simulated data if the API call fails + return NextResponse.json({ + coins: [ + { id: 'bitcoin', name: 'Bitcoin', symbol: 'BTC', market_cap_rank: 1, price_btc: 1, thumb: '/icons/btc.svg', score: 0 }, + { id: 'ethereum', name: 'Ethereum', symbol: 'ETH', market_cap_rank: 2, price_btc: 0.05, thumb: '/icons/eth.svg', score: 0 }, + { id: 'solana', name: 'Solana', symbol: 'SOL', market_cap_rank: 5, price_btc: 0.0012, thumb: '/icons/sol.svg', score: 0 }, + { id: 'cardano', name: 'Cardano', symbol: 'ADA', market_cap_rank: 8, price_btc: 0.00002, thumb: '/icons/ada.svg', score: 0 }, + { id: 'polkadot', name: 'Polkadot', symbol: 'DOT', market_cap_rank: 12, price_btc: 0.00018, thumb: '/icons/dot.svg', score: 0 }, + ], + timestamp: Date.now(), + simulated: true + }); + } +} diff --git a/app/api/analytics/whale-alerts/route.ts b/app/api/analytics/whale-alerts/route.ts new file mode 100644 index 0000000..e094515 --- /dev/null +++ b/app/api/analytics/whale-alerts/route.ts @@ -0,0 +1,151 @@ +import { NextResponse } from 'next/server'; +import axios from 'axios'; + +export async function GET(req: Request) { + try { + const apiKey = process.env.ETHERSCAN_API_KEY; + + if (!apiKey) { + throw new Error("Etherscan API key is not configured"); + } + + // Extract query parameters + const url = new URL(req.url); + const limit = Number(url.searchParams.get('limit')) || 10; + + // Get recent ETH transactions from a block + // We'll take the most recent block and look for large transactions + const blockNumberResponse = await axios.get( + `https://api.etherscan.io/api?module=proxy&action=eth_blockNumber&apikey=${apiKey}` + ); + + if (!blockNumberResponse.data || !blockNumberResponse.data.result) { + throw new Error("Failed to get latest block number"); + } + + const blockNumber = parseInt(blockNumberResponse.data.result, 16); + + // Get block details including transactions + const blockResponse = await axios.get( + `https://api.etherscan.io/api?module=proxy&action=eth_getBlockByNumber&tag=0x${blockNumber.toString(16)}&boolean=true&apikey=${apiKey}` + ); + + if (!blockResponse.data || !blockResponse.data.result || !blockResponse.data.result.transactions) { + throw new Error("Failed to get block details"); + } + + // Extract and filter large transactions (> 10 ETH) + const transactions = blockResponse.data.result.transactions + .filter((tx: any) => { + const valueInEth = parseInt(tx.value, 16) / 1e18; + return valueInEth > 10; // Only transactions > 10 ETH + }) + .map((tx: any) => { + const valueInEth = parseInt(tx.value, 16) / 1e18; + const timestamp = parseInt(blockResponse.data.result.timestamp, 16) * 1000; + + return { + id: tx.hash, + symbol: 'ETH', + amount: parseFloat(valueInEth.toFixed(2)), + value: valueInEth * 3000, // Approximate USD value + from: tx.from, + to: tx.to, + type: 'transfer', + timestamp + }; + }) + .slice(0, limit); + + if (transactions.length > 0) { + const whaleData = { + transactions, + totalValue: transactions.reduce((sum: number, tx: { value: number }) => sum + tx.value, 0), + timestamp: Date.now() + }; + + return NextResponse.json(whaleData, { status: 200 }); + } + + // If no large transactions found or not enough, throw to use simulated data + throw new Error("Not enough large transactions found in latest block"); + } catch (error) { + console.error('Error in whale alerts API:', error); + + // Define mock transaction data as fallback + const cryptoAddresses = [ + '0x6b75d8af000000e20b7a7ddf000ba0d00b', + '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + '0x73bceb1cd57c711feac4224d062b0f6ff338501e', + 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + ]; + + const exchanges = [ + 'Binance', + 'Coinbase', + 'Kraken', + 'Huobi', + 'KuCoin', + 'FTX', + 'Unknown' + ]; + + const cryptoSymbols = ['BTC', 'ETH', 'BNB', 'SOL', 'USDT', 'USDC']; + + // Extract query parameters + const url = new URL(req.url); + const limit = Number(url.searchParams.get('limit')) || 10; + + // Generate simulated whale transactions + const transactions = Array.from({ length: limit }, (_, i) => { + const isFromExchange = Math.random() > 0.5; + const isToExchange = !isFromExchange && Math.random() > 0.7; + const symbol = cryptoSymbols[Math.floor(Math.random() * cryptoSymbols.length)]; + const amount = symbol === 'BTC' ? + Math.random() * 500 + 50 : + symbol === 'ETH' ? + Math.random() * 5000 + 500 : + Math.random() * 100000 + 10000; + + const baseValue = + symbol === 'BTC' ? amount * 60000 : + symbol === 'ETH' ? amount * 3500 : + symbol === 'BNB' ? amount * 600 : + symbol === 'SOL' ? amount * 150 : + amount; + + // Add some randomness to value + const value = baseValue * (0.9 + Math.random() * 0.2); + + const timestamp = Date.now() - Math.floor(Math.random() * 24 * 60 * 60 * 1000); + + return { + id: `tx_${Date.now()}_${i}`, + symbol, + amount: parseFloat(amount.toFixed(symbol === 'BTC' ? 2 : symbol === 'ETH' ? 1 : 0)), + value: parseFloat(value.toFixed(0)), + from: isFromExchange ? + exchanges[Math.floor(Math.random() * (exchanges.length - 1))] : + cryptoAddresses[Math.floor(Math.random() * cryptoAddresses.length)], + to: isToExchange ? + exchanges[Math.floor(Math.random() * (exchanges.length - 1))] : + cryptoAddresses[Math.floor(Math.random() * cryptoAddresses.length)], + type: isFromExchange ? 'withdrawal' : isToExchange ? 'deposit' : 'transfer', + timestamp + }; + }); + + // Sort by most recent first + transactions.sort((a, b) => b.timestamp - a.timestamp); + + const whaleData = { + transactions, + totalValue: transactions.reduce((sum: number, tx: { value: number }) => sum + tx.value, 0), + timestamp: Date.now(), + simulated: true + }; + + return NextResponse.json(whaleData, { status: 200 }); + } +} diff --git a/app/api/binance/klines/route.ts b/app/api/binance/klines/route.ts new file mode 100644 index 0000000..f7d4d2d --- /dev/null +++ b/app/api/binance/klines/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const symbol = url.searchParams.get("symbol"); + const interval = url.searchParams.get("interval") || "1d"; + const limit = url.searchParams.get("limit") || "30"; + + if (!symbol) { + return new NextResponse( + JSON.stringify({ error: "Symbol parameter is required" }), + { status: 400 } + ); + } + + // Binance API doesn't require auth for basic endpoints + const response = await axios.get( + "https://api.binance.com/api/v3/klines", + { + params: { + symbol: `${symbol.toUpperCase()}USDT`, + interval, + limit + } + } + ); + + // Transform to the format our charts expect + const prices = response.data.map((kline: any) => [kline[0], parseFloat(kline[4])]); + + // Estimate market cap and volume + const market_cap_multiplier = getMarketCapMultiplier(symbol); + const market_caps = prices.map(([time, price]: [number, number]) => + [time, price * market_cap_multiplier] + ); + + const volumes = response.data.map((kline: any) => + [kline[0], parseFloat(kline[5]) * parseFloat(kline[4])] + ); + + return NextResponse.json({ + prices, + market_caps, + total_volumes: volumes + }); + } catch (error: any) { + console.error("Binance API Error:", error.response?.data || error.message); + + if (error.response?.status === 400 && error.response?.data?.msg?.includes('Invalid symbol')) { + const url = new URL(request.url); + console.warn(`Symbol ${url.searchParams.get("symbol")}USDT not found on Binance`); + return new NextResponse( + JSON.stringify({ + error: "Invalid symbol", + details: "The requested trading pair does not exist on Binance" + }), + { status: 400 } + ); + } + + return new NextResponse( + JSON.stringify({ + error: "Failed to fetch data from Binance", + details: error.response?.data?.msg || error.message + }), + { status: error.response?.status || 500 } + ); + } +} + +function getMarketCapMultiplier(symbol: string): number { + // Rough estimates of circulating supply for major coins + switch (symbol.toLowerCase()) { + case 'btc': return 19500000; // ~19.5M BTC in circulation + case 'eth': return 120000000; // ~120M ETH in circulation + case 'bnb': return 153000000; // ~153M BNB in circulation + case 'sol': return 430000000; // ~430M SOL in circulation + default: return 100000000; // Default fallback + } +} diff --git a/app/api/coinmarketcap/global-metrics/route.ts b/app/api/coinmarketcap/global-metrics/route.ts new file mode 100644 index 0000000..a70f1ed --- /dev/null +++ b/app/api/coinmarketcap/global-metrics/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET() { + try { + // Using CoinGecko's free global data endpoint instead of CoinMarketCap + const response = await axios.get( + "https://api.coingecko.com/api/v3/global" + ); + + if (!response.data || !response.data.data) { + throw new Error("Invalid response from CoinGecko API"); + } + + // Transform CoinGecko response to match the expected format + const data = { + status: { + timestamp: new Date().toISOString(), + error_code: 0, + error_message: null, + }, + data: { + active_cryptocurrencies: response.data.data.active_cryptocurrencies, + total_cryptocurrencies: response.data.data.active_cryptocurrencies, // CoinGecko doesn't provide total count + total_market_cap: response.data.data.total_market_cap, + total_volume_24h: response.data.data.total_volume, + btc_dominance: response.data.data.market_cap_percentage.btc, + eth_dominance: response.data.data.market_cap_percentage.eth, + market_cap_change_24h: response.data.data.market_cap_change_percentage_24h_usd, + } + }; + + return NextResponse.json(data); + } catch (error: any) { + console.error("CoinGecko API Error:", error.message); + + return new NextResponse( + JSON.stringify({ + error: "Failed to fetch data from CoinGecko", + details: error.response?.data?.error || error.message + }), + { status: error.response?.status || 500 } + ); + } +} diff --git a/app/api/coinmarketcap/historical/route.ts b/app/api/coinmarketcap/historical/route.ts new file mode 100644 index 0000000..0d52593 --- /dev/null +++ b/app/api/coinmarketcap/historical/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + try { + const url = new URL(request.url); + const symbol = url.searchParams.get("symbol") || "BTC"; + const time_period = url.searchParams.get("time_period") || "30d"; + const days = parseInt(time_period.replace('d', '')) || 30; + + console.log(`Historical data requested for ${symbol} over ${days} days`); + console.warn("CoinMarketCap free API does not support historical data, returning simulated data"); + + // Generate simulated data + const data = generateMockHistoricalData(symbol, days); + + return NextResponse.json(data); + } catch (error: any) { + console.error("Error in historical data endpoint:", error.message); + + return new NextResponse( + JSON.stringify({ + error: "Failed to generate historical data", + details: error.message + }), + { status: 500 } + ); + } +} + +function generateMockHistoricalData(symbol: string, days: number) { + // Start with a base price depending on the symbol + let basePrice = 0; + switch (symbol.toLowerCase()) { + case 'btc': basePrice = 60000; break; + case 'eth': basePrice = 3000; break; + case 'sol': basePrice = 130; break; + case 'bnb': basePrice = 550; break; + default: basePrice = 100; + } + + const prices = []; + const market_caps = []; + const total_volumes = []; + + const now = Date.now(); + const oneDayMs = 24 * 60 * 60 * 1000; + + // Create a consistently upward or downward trend for the overall chart + const trendDirection = Math.random() > 0.5 ? 1 : -1; + const trendStrength = Math.random() * 0.01 + 0.005; // Between 0.5% and 1.5% daily trend + + for (let i = days; i >= 0; i--) { + const timestamp = now - (i * oneDayMs); + + // Add some random fluctuation plus the overall trend + const dailyTrend = trendDirection * trendStrength * basePrice * (days - i) / days; + const randomChange = basePrice * (Math.random() * 0.06 - 0.03); // Random -3% to +3% + basePrice += randomChange + dailyTrend; + + if (basePrice < 0) basePrice = Math.abs(randomChange); // Prevent negative prices + + prices.push([timestamp, basePrice]); + market_caps.push([timestamp, basePrice * getMarketCapMultiplier(symbol)]); + total_volumes.push([timestamp, basePrice * getVolumeMultiplier(symbol) * (0.7 + Math.random() * 0.6)]); + } + + return { + prices, + market_caps, + total_volumes + }; +} + +function getMarketCapMultiplier(symbol: string): number { + // Rough estimates of circulating supply for major coins + switch (symbol.toLowerCase()) { + case 'btc': return 19500000; // ~19.5M BTC in circulation + case 'eth': return 120000000; // ~120M ETH in circulation + case 'bnb': return 153000000; // ~153M BNB in circulation + case 'sol': return 430000000; // ~430M SOL in circulation + default: return 100000000; // Default fallback + } +} + +function getVolumeMultiplier(symbol: string): number { + // Volume multipliers based on typical daily trading volume as % of market cap + switch (symbol.toLowerCase()) { + case 'btc': return 500000; // Higher volume for BTC + case 'eth': return 300000; + default: return 200000; + } +} diff --git a/app/api/coinmarketcap/listings/route.ts b/app/api/coinmarketcap/listings/route.ts new file mode 100644 index 0000000..37f163c --- /dev/null +++ b/app/api/coinmarketcap/listings/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET(request: Request) { + try { + const apiKey = process.env.COINMARKETCAP_API_KEY; + const url = new URL(request.url); + const limit = url.searchParams.get("limit") || "10"; + const sort = url.searchParams.get("sort") || "market_cap"; + const sort_dir = url.searchParams.get("sort_dir") || "desc"; + + if (!apiKey) { + console.warn("CoinMarketCap API key is not configured"); + return new NextResponse( + JSON.stringify({ error: "API key is not configured" }), + { status: 500 } + ); + } + + const response = await axios.get( + "https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest", + { + headers: { + "X-CMC_PRO_API_KEY": apiKey, + }, + params: { + limit, + sort, + sort_dir, + convert: "USD" + } + } + ); + + return NextResponse.json(response.data); + } catch (error: any) { + // Log detailed error for debugging + if (error.response?.data) { + const errorData = error.response.data; + console.error(`CoinMarketCap API Error (${error.response.status}):`, errorData); + + // Check for specific error codes from CoinMarketCap + if (errorData.status?.error_code === 1002) { + console.warn("CoinMarketCap API Key is invalid or authorization failed"); + } else if (errorData.status?.error_code === 1006) { + console.warn("CoinMarketCap API request exceeds available plan limit"); + } + } else { + console.error("CoinMarketCap API Error:", error.message); + } + + return new NextResponse( + JSON.stringify({ + error: "Failed to fetch data from CoinMarketCap", + details: error.response?.data?.status?.error_message || error.message, + code: error.response?.data?.status?.error_code + }), + { status: error.response?.status || 500 } + ); + } +} diff --git a/app/api/coinmarketcap/route.ts b/app/api/coinmarketcap/route.ts new file mode 100644 index 0000000..bba2835 --- /dev/null +++ b/app/api/coinmarketcap/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server'; + +const CMC_API_KEY = process.env.COINMARKETCAP_API_KEY; +const CMC_API_URL = 'https://pro-api.coinmarketcap.com/v1'; + +export async function GET(request: Request) { + const url = new URL(request.url); + const endpoint = url.pathname.split('/coinmarketcap/')[1]; + + try { + if (!CMC_API_KEY) { + console.warn('CoinMarketCap API key not configured, using fallback data'); + throw new Error('API key not configured'); + } + + // Select the appropriate endpoint + let apiEndpoint = ''; + switch (endpoint) { + case 'global-metrics': + apiEndpoint = `${CMC_API_URL}/global-metrics/quotes/latest`; + break; + case 'listings': + apiEndpoint = `${CMC_API_URL}/cryptocurrency/listings/latest`; + break; + default: + throw new Error('Invalid endpoint'); + } + + // Forward query parameters + const queryParams = new URLSearchParams(); + url.searchParams.forEach((value, key) => { + queryParams.append(key, value); + }); + + // Add default parameters if not provided + if (!queryParams.has('convert')) { + queryParams.append('convert', 'USD'); + } + + const response = await fetch(`${apiEndpoint}?${queryParams}`, { + headers: { + 'X-CMC_PRO_API_KEY': CMC_API_KEY, + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`CoinMarketCap API error: ${response.status}`); + } + + const data = await response.json(); + + return NextResponse.json(data); + } catch (error: any) { + console.error('CoinMarketCap API proxy error:', error); + + // Return fallback data based on the endpoint + if (url.pathname.includes('global-metrics')) { + return NextResponse.json({ + data: { + quote: { + USD: { + total_market_cap: 2300000000000, + total_volume_24h: 115000000000, + total_market_cap_yesterday_percentage_change: 2.5 + } + }, + total_cryptocurrencies: 10423, + active_market_pairs: 814, + btc_dominance: 48.5, + eth_dominance: 17.3, + last_updated: new Date().toISOString() + } + }); + } else { + return NextResponse.json({ + data: [ + { + id: 1, + name: 'Bitcoin', + symbol: 'BTC', + cmc_rank: 1, + quote: { + USD: { + price: 65000, + market_cap: 1300000000000, + volume_24h: 28000000000, + percent_change_24h: 2.5 + } + } + }, + // Add more fallback tokens as needed + ] + }); + } + } +} \ No newline at end of file diff --git a/app/api/market/altcoin-season/route.ts b/app/api/market/altcoin-season/route.ts new file mode 100644 index 0000000..b9deebb --- /dev/null +++ b/app/api/market/altcoin-season/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +// Calculate Altcoin Season Index based on BTC dominance and altcoin performance +export async function GET() { + try { + // Step 1: Get BTC dominance from Binance API (uses CoinMarketCap data) + const dominanceResponse = await axios.get(`https://api.binance.com/api/v3/ticker/24hr?symbol=BTCUSDT`); + + // Step 2: Get data for top altcoins + const altcoinsSymbols = ["ETHUSDT", "BNBUSDT", "SOLUSDT", "ADAUSDT"]; + const altcoinPromises = altcoinsSymbols.map(symbol => + axios.get(`https://api.binance.com/api/v3/ticker/24hr?symbol=${symbol}`) + ); + + const altcoinResponses = await Promise.allSettled(altcoinPromises); + + // Step 3: Calculate altcoin performance + let altcoinPerformance = 0; + let successfulResponses = 0; + + altcoinResponses.forEach(response => { + if (response.status === 'fulfilled' && response.value.data) { + const priceChangePercent = parseFloat(response.value.data.priceChangePercent); + if (!isNaN(priceChangePercent)) { + altcoinPerformance += priceChangePercent; + successfulResponses++; + } + } + }); + + if (successfulResponses > 0) { + altcoinPerformance /= successfulResponses; + } + + // Step 4: Get BTC dominance from CoinMarketCap if possible + let btcDominance = 60; // Default value if we can't get real data + + try { + const cmcResponse = await axios.get('/api/coinmarketcap/global-metrics'); + if (cmcResponse.data && cmcResponse.data.data && cmcResponse.data.data.btc_dominance) { + btcDominance = cmcResponse.data.data.btc_dominance; + } + } catch (error) { + console.error("Could not fetch BTC dominance from CoinMarketCap", error); + } + + // Step 5: Calculate Altcoin Season Index (0-100 scale) + // Higher BTC dominance means less altcoin season + // Higher altcoin performance relative to BTC means more altcoin season + const btcPerformance = parseFloat(dominanceResponse.data.priceChangePercent); + + // Base index on BTC dominance (inverted, scaled to 0-75) + let altcoinIndex = 100 - Math.min(100, Math.max(0, btcDominance * 1.25)); + + // Adjust based on relative performance of altcoins vs BTC (adds or subtracts up to 25 points) + if (!isNaN(btcPerformance) && !isNaN(altcoinPerformance)) { + const performanceDiff = altcoinPerformance - btcPerformance; + altcoinIndex += Math.min(25, Math.max(-25, performanceDiff * 2)); + } + + // Ensure the index stays within 0-100 range + altcoinIndex = Math.min(100, Math.max(0, altcoinIndex)); + + // Calculate the season text + let seasonText = "Neutral"; + if (altcoinIndex < 25) seasonText = "Bitcoin Season"; + else if (altcoinIndex < 45) seasonText = "Bitcoin Favored"; + else if (altcoinIndex < 55) seasonText = "Neutral"; + else if (altcoinIndex < 75) seasonText = "Altcoin Favored"; + else seasonText = "Altcoin Season"; + + return NextResponse.json({ + value: Math.round(altcoinIndex), + valueText: seasonText, + btcDominance: btcDominance, + timestamp: Date.now() / 1000 + }); + } catch (error) { + console.error("Error calculating Altcoin Season Index:", error); + + // If API calls fail, return simulated data + const simulatedValue = Math.floor(Math.random() * 100) + 1; + let valueText = "Neutral"; + + if (simulatedValue <= 25) valueText = "Bitcoin Season"; + else if (simulatedValue < 45) valueText = "Bitcoin Favored"; + else if (simulatedValue < 55) valueText = "Neutral"; + else if (simulatedValue < 75) valueText = "Altcoin Favored"; + else valueText = "Altcoin Season"; + + return NextResponse.json({ + value: simulatedValue, + valueText, + btcDominance: 60 + (Math.random() * 10 - 5), + timestamp: Date.now() / 1000, + simulated: true + }); + } +} diff --git a/app/api/market/fear-greed/route.ts b/app/api/market/fear-greed/route.ts new file mode 100644 index 0000000..66ebcd8 --- /dev/null +++ b/app/api/market/fear-greed/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import axios from "axios"; + +export async function GET() { + try { + // The Alternative.me Fear & Greed Index API is free and doesn't require an API key + const response = await axios.get("https://api.alternative.me/fng/"); + + if (response.data && response.data.data && response.data.data[0]) { + return NextResponse.json({ + value: parseInt(response.data.data[0].value), + valueText: response.data.data[0].value_classification, + timestamp: response.data.data[0].timestamp + }); + } else { + throw new Error("Invalid response format from Fear & Greed API"); + } + } catch (error) { + console.error("Error fetching Fear & Greed Index:", error); + + // If the API call fails, generate a realistic simulated value + const simulatedValue = Math.floor(Math.random() * 100) + 1; + let valueText = "Neutral"; + + if (simulatedValue <= 25) valueText = "Extreme Fear"; + else if (simulatedValue <= 40) valueText = "Fear"; + else if (simulatedValue <= 60) valueText = "Neutral"; + else if (simulatedValue <= 80) valueText = "Greed"; + else valueText = "Extreme Greed"; + + return NextResponse.json({ + value: simulatedValue, + valueText, + timestamp: Date.now() / 1000, + simulated: true + }); + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 2c3ba0a..0d8bfee 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -22,63 +22,31 @@ import okxModule from "@web3-onboard/okx"; import frontierModule from "@web3-onboard/frontier"; import { useAuth } from "@/lib/context/AuthContext"; import { useSettings } from "@/components/context/SettingsContext"; -import CryptoJS from "crypto-js"; // Still imported but not used in encryption here -import bcrypt from "bcryptjs"; // Import bcryptjs +import { Subscription } from "@supabase/supabase-js"; +import bcrypt from "bcryptjs"; // Thêm bcryptjs để hash mật khẩu +import crypto from "crypto"; // Dùng crypto để mã hóa dữ liệu -// Secret key (should ideally be stored in environment variables) -const SECRET_KEY = process.env.NEXT_PUBLIC_ENCRYPTION_KEY || "my-secret-key-1234567890"; - -// Function to hash a password -const hashPassword = (password: string): string => { - const salt = bcrypt.genSaltSync(10); - return bcrypt.hashSync(password, salt); -}; - -// Function to hash data (replacing encryptData) -const encryptData = (data: string): string => { - return bcrypt.hashSync(data, 10); -}; - -// Function to "decrypt" data (not possible with bcrypt) -const decryptData = (encryptedData: string): string => { - // bcrypt is a one-way hashing algorithm, so we cannot decrypt - throw new Error("Cannot decrypt data hashed with bcrypt"); -}; - -// Rest of your wallet configurations remain unchanged -const dcent = dcentModule(); +// Web3-Onboard configuration const INFURA_KEY = "7d389678fba04ceb9510b2be4fff5129"; - const walletConnect = walletConnectModule({ projectId: "b773e42585868b9b143bb0f1664670f1", optionalChains: [1, 137], }); -const injected = injectedModule(); -const coinbase = coinbaseModule(); -const infinityWallet = infinityWalletModule(); -const safe = safeModule(); -const sequence = sequenceModule(); -const taho = tahoModule(); -const trust = trustModule(); -const okx = okxModule(); -const frontier = frontierModule(); -const trezor = trezorModule({ email: "test@test.com", appUrl: "https://www.blocknative.com" }); -const magic = magicModule({ apiKey: "pk_live_E9B0C0916678868E" }); - const wallets = [ - infinityWallet, - sequence, - injected, - trust, - okx, - frontier, - taho, - coinbase, - dcent, + infinityWalletModule(), + sequenceModule(), + injectedModule(), + trustModule(), + okxModule(), + frontierModule(), + tahoModule(), + coinbaseModule(), + dcentModule(), walletConnect, - safe, - magic, + safeModule(), + magicModule({ apiKey: "pk_live_E9B0C0916678868E" }), + trezorModule({ email: "test@test.com", appUrl: "https://www.blocknative.com" }), ]; const chains = [ @@ -104,43 +72,103 @@ const appMetadata = { const web3Onboard = init({ wallets, chains, appMetadata }); +// Debounce utility with proper TypeScript types +const debounce = void>( + func: T, + wait: number +): ((...args: Parameters) => void) => { + let timeout: NodeJS.Timeout | undefined; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +}; + +// Khóa bí mật để mã hóa dữ liệu (nên lưu trong biến môi trường trong thực tế) +const ENCRYPTION_KEY = process.env.NEXT_PUBLIC_ENCRYPTION_KEY || "your-secret-key-here-32bytes-long"; +const IV_LENGTH = 16; // Độ dài IV cho AES + +// Hàm mã hóa dữ liệu +const encryptData = (text: string): string => { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(ENCRYPTION_KEY), iv); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return iv.toString("hex") + ":" + encrypted.toString("hex"); +}; + +// Hàm giải mã dữ liệu +const decryptData = (text: string): string => { + const [iv, encryptedText] = text.split(":"); + const decipher = crypto.createDecipheriv( + "aes-256-cbc", + Buffer.from(ENCRYPTION_KEY), + Buffer.from(iv, "hex") + ); + let decrypted = decipher.update(Buffer.from(encryptedText, "hex")); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); +}; + function LoginPageContent() { const router = useRouter(); const { signInWithWalletConnect, signIn } = useAuth(); const { updateProfile, addWallet, syncWithSupabase } = useSettings(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [emailError, setEmailError] = useState(""); - const [passwordError, setPasswordError] = useState(""); - const [showPassword, setShowPassword] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [emailError, setEmailError] = useState(""); + const [passwordError, setPasswordError] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [{ wallet, connecting }, connect, disconnect] = useConnectWallet(); - const [isLoggedOut, setIsLoggedOut] = useState(false); + const [isLoggedOut, setIsLoggedOut] = useState(false); interface Account { address: string; ens: string | null; } - const formatWalletAddress = (walletAddress: string) => { + const [account, setAccount] = useState(null); + + const formatWalletAddress = (walletAddress: string): string => { if (!walletAddress) return ""; return `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`; }; - const [account, setAccount] = useState(null); - + // Check session and listen to auth state changes useEffect(() => { const checkExistingSession = async () => { - const { data: { session } } = await supabase.auth.getSession(); + const { data: { session }, error } = await supabase.auth.getSession(); + if (error) { + console.error("Error checking session:", error.message); + return; + } if (session) { + console.log("Initial session found, redirecting to dashboard"); router.push("/"); } }; checkExistingSession(); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { + console.log("Auth state changed:", event); + if (event === "SIGNED_IN" && session) { + console.log("User signed in, redirecting to dashboard"); + router.push("/"); + } else if (event === "SIGNED_OUT") { + console.log("User signed out, staying on login page"); + setIsLoggedOut(true); + } + }); + + return () => { + subscription.unsubscribe(); + }; }, [router]); + // Handle wallet connection and authentication useEffect(() => { if (wallet?.provider && !isLoggedOut) { const { address, ens } = wallet.accounts[0]; @@ -149,11 +177,27 @@ function LoginPageContent() { const authenticateWithWallet = async () => { try { setIsLoading(true); + const hashedAddress = await bcrypt.hash(address, 10); // Hash địa chỉ ví const { data, error } = await signInWithWalletConnect(address); if (error) { - console.error("Wallet auth error:", error); - toast.error(`Failed to authenticate with wallet: ${error.message}`); - return; + if (error.message.includes("User already registered")) { + toast.info("User already exists. Logging in instead."); + const { data: loginData, error: loginError } = await supabase.auth.signInWithPassword({ + email: `${address}@cryptopath.local`, + password: hashedAddress, // Sử dụng hashed address làm mật khẩu + }); + if (loginError) throw new Error(loginError.message); + if (!loginData.session) throw new Error("No session returned from login"); + const encryptedToken = encryptData(JSON.stringify(loginData.session.access_token)); + localStorage.setItem("userToken", encryptedToken); + } else { + throw new Error(error.message); + } + } else if (!data || !data.session) { + throw new Error("No session data returned from sign-in"); + } else { + const encryptedToken = encryptData(JSON.stringify(data.session.access_token)); + localStorage.setItem("userToken", encryptedToken); } updateProfile({ @@ -169,15 +213,16 @@ function LoginPageContent() { name: ens?.name || formatWalletAddress(address), isLoggedIn: true, }; - // Hash before storing in localStorage - localStorage.setItem("userDisplayInfo", encryptData(JSON.stringify(publicUserData))); - localStorage.setItem("userToken", encryptData(data.session?.access_token || "")); + const encryptedUserData = encryptData(JSON.stringify(publicUserData)); + localStorage.setItem("userDisplayInfo", encryptedUserData); + console.log("Wallet login successful, redirecting to dashboard"); toast.success("Successfully authenticated with wallet"); router.push("/"); - } catch (error: any) { - console.error("Error authenticating with wallet:", error); - toast.error(`Authentication failed: ${error?.message || "Unknown error"}`); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error("Wallet authentication error:", error); + toast.error(`Authentication failed: ${errorMessage}`); } finally { setIsLoading(false); } @@ -187,6 +232,7 @@ function LoginPageContent() { } }, [wallet, router, isLoggedOut, signInWithWalletConnect, updateProfile, addWallet, syncWithSupabase]); + // Handle email/password login const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setEmailError(""); @@ -194,20 +240,17 @@ function LoginPageContent() { setIsLoading(true); try { - // Hash the password before sending it to the signIn function - const hashedPassword = hashPassword(password); - const { data, error } = await signIn(email, hashedPassword); // Assuming signIn accepts hashed password + const hashedPassword = await bcrypt.hash(password, 10); // Hash mật khẩu trước khi gửi + const { data, error } = await signIn(email, hashedPassword); // Gửi hashed password if (error) { if (error.message.includes("email")) setEmailError(error.message); else if (error.message.includes("password")) setPasswordError(error.message); else toast.error(error.message); - setIsLoading(false); return; } - if (!data.user) { + if (!data.user || !data.session) { toast.error("Something went wrong with the login"); - setIsLoading(false); return; } @@ -230,25 +273,29 @@ function LoginPageContent() { email, isLoggedIn: true, }; - // Hash before storing in localStorage - localStorage.setItem("currentUser", encryptData(JSON.stringify(publicUserData))); - localStorage.setItem("userToken", encryptData(data.session?.access_token || "")); + const encryptedUserData = encryptData(JSON.stringify(publicUserData)); + const encryptedToken = encryptData(JSON.stringify(data.session.access_token)); + localStorage.setItem("currentUser", encryptedUserData); + localStorage.setItem("userToken", encryptedToken); + console.log("Email login successful, redirecting to dashboard"); toast.success("Login successful!"); router.push("/"); - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; console.error("Login error:", error); - toast.error("An unexpected error occurred. Please try again."); + toast.error(`An unexpected error occurred: ${errorMessage}`); } finally { setIsLoading(false); } }; - const handleWalletConnect = async () => { + // Handle wallet connect/disconnect with debounce + const handleWalletConnect = debounce(async () => { if (!wallet) { - connect(); + await connect(); } else { - disconnect({ label: wallet.label }); + await disconnect({ label: wallet.label }); setAccount(null); setIsLoggedOut(true); await supabase.auth.signOut(); @@ -256,9 +303,8 @@ function LoginPageContent() { localStorage.removeItem("userToken"); router.push("/login"); } - }; + }, 1000); - // The rest of your JSX remains unchanged return ( <>
@@ -347,9 +393,10 @@ function LoginPageContent() { options: { redirectTo: `${window.location.origin}/` }, }); if (error) throw error; - } catch (error) { + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; console.error("Google login error:", error); - toast.error("Google login failed. Please try again."); + toast.error(`Google login failed: ${errorMessage}`); } finally { setIsLoading(false); } diff --git a/app/market-overview/page.tsx b/app/market-overview/page.tsx new file mode 100644 index 0000000..9cfc5c4 --- /dev/null +++ b/app/market-overview/page.tsx @@ -0,0 +1,353 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import ParticlesBackground from '@/components/ParticlesBackground'; +import { + ArrowUpRight, Clock, Info, ChevronDown +} from 'lucide-react'; +import { toast } from "sonner"; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Skeleton } from '@/components/ui/skeleton'; +import Link from 'next/link'; +import { + fetchGlobalMarketData, + fetchTopCryptocurrencies, + fetchHistoricalData +} from '@/services/coinMarketCapService'; +import CryptoCard from '@/components/market-overview/CryptoCard'; +import MarketCapChart from '@/components/market-overview/MarketCapChart'; +import DominanceCard from '@/components/market-overview/DominanceCard'; +import FearGreedIndex from '@/components/market-overview/FearGreedIndex'; +import AltcoinIndex from '@/components/market-overview/AltcoinIndex'; +import MarketIndexCard from '@/components/market-overview/MarketIndexCard'; +import WhaleAlertsCard from '@/components/market-overview/WhaleAlertsCard'; +import TrendingCoinsCard from '@/components/market-overview/TrendingCoinsCard'; +import BlockchainStatsCard from '@/components/market-overview/BlockchainStatsCard'; +import GasPriceCard from '@/components/market-overview/GasPriceCard'; +import ExchangeVolumeCard from '@/components/market-overview/ExchangeVolumeCard'; +import DefiTvlCard from '@/components/market-overview/DefiTvlCard'; +import NftStatsCard from '@/components/market-overview/NftStatsCard'; +import MarketSentimentCard from '@/components/market-overview/MarketSentimentCard'; + +export default function MarketOverview() { + const [marketData, setMarketData] = useState(null); + const [topTokens, setTopTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [chartTimeframe, setChartTimeframe] = useState('30d'); + const [btcHistoricalData, setBtcHistoricalData] = useState(null); + const [dataLastUpdated, setDataLastUpdated] = useState(null); + const [dataSource, setDataSource] = useState('live'); + + useEffect(() => { + const fetchMarketData = async () => { + setLoading(true); + try { + // Fetch global market data from CoinMarketCap + const data = await fetchGlobalMarketData(); + setMarketData(data); + + // Fetch top tokens from CoinMarketCap + const tokens = await fetchTopCryptocurrencies(5); + setTopTokens(tokens); + + // Try Binance API first for BTC historical data + try { + const btcData = await fetch(`/api/binance/klines?symbol=BTC&interval=1d&limit=30`) + .then(res => { + if (!res.ok) throw new Error('Failed to fetch from Binance'); + return res.json(); + }); + + setBtcHistoricalData(btcData); + setDataSource('binance'); + } catch (binanceError) { + console.warn('Failed to fetch from Binance, using simulated data:', binanceError); + + // Fallback to simulated data + const btcData = await fetchHistoricalData('BTC', 30); + setBtcHistoricalData(btcData); + setDataSource('simulated'); + } + + setDataLastUpdated(new Date()); + } catch (error) { + console.error('Error fetching market data:', error); + toast.error('Failed to load market data. Using backup data.'); + + // Set simulated data as fallback + const data = { + total_market_cap: { usd: 1000000000000 }, + total_volume: { usd: 50000000000 }, + market_cap_percentage: { btc: 60, eth: 20 }, + active_cryptocurrencies: 5000, + markets: 10000 + }; + setMarketData(data); + + const tokens = [ + { id: 1, name: 'Bitcoin', symbol: 'BTC', current_price: 50000, price_change_percentage_24h: 2 }, + { id: 2, name: 'Ethereum', symbol: 'ETH', current_price: 4000, price_change_percentage_24h: 3 }, + { id: 3, name: 'Binance Coin', symbol: 'BNB', current_price: 600, price_change_percentage_24h: 1 }, + { id: 4, name: 'Cardano', symbol: 'ADA', current_price: 2, price_change_percentage_24h: -1 }, + { id: 5, name: 'Solana', symbol: 'SOL', current_price: 150, price_change_percentage_24h: 4 } + ]; + setTopTokens(tokens); + + const btcData = await fetchHistoricalData('BTC', 30); + setBtcHistoricalData(btcData); + + setDataLastUpdated(new Date()); + } finally { + setLoading(false); + } + }; + + fetchMarketData(); + + // Refresh data every 5 minutes instead of 2 to avoid API rate limits + const interval = setInterval(fetchMarketData, 5 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + // Format large numbers + const formatNumber = (num: number, decimals = 2): string => { + if (num >= 1000000000000) { + return `$${(num / 1000000000000).toFixed(decimals)}T`; + } else if (num >= 1000000000) { + return `$${(num / 1000000000).toFixed(decimals)}B`; + } else if (num >= 1000000) { + return `$${(num / 1000000).toFixed(decimals)}M`; + } else if (num >= 1000) { + return `$${(num / 1000).toFixed(decimals)}K`; + } else { + return `$${num.toFixed(decimals)}`; + } + }; + + const renderSkeleton = () => ( +
+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ +
+ + +
+ +
+ + +
+
+ ); + + return ( +
+ + +
+
+
+

Crypto Market Overview

+
+
+ + {dataLastUpdated ? + `Updated ${dataLastUpdated.toLocaleTimeString()}` : + 'Fetching data...'} +
+ + + {dataSource === 'simulated' && ( + + Simulated Data + + )} +
+
+

+ Live cryptocurrency market data powered by Binance and CoinMarketCap APIs, including Bitcoin price, market dominance, and sentiment indicators. +

+
+ + {loading ? ( + renderSkeleton() + ) : marketData && topTokens.length > 0 ? ( + <> + {/* Top Cryptocurrencies Cards */} +
+ {topTokens.map((token, index) => ( + + ))} +
+ +
+ {/* Bitcoin Price Chart (2/3 width) */} +
+ + + Bitcoin Price +
+ + + 24h + 7d + 30d + 1y + + +
+
+ +
+
+
Price
+
+ {btcHistoricalData && btcHistoricalData.prices && btcHistoricalData.prices.length > 0 + ? `$${Number(btcHistoricalData.prices[btcHistoricalData.prices.length-1][1]).toLocaleString()}` + : `$${formatNumber(60000)}`} +
+
+
+
24h Volume
+
+ {btcHistoricalData && btcHistoricalData.total_volumes && btcHistoricalData.total_volumes.length > 0 + ? formatNumber(btcHistoricalData.total_volumes[btcHistoricalData.total_volumes.length-1][1]) + : formatNumber(30000000000)} +
+
+
+ + +
+
+
+ + {/* Bitcoin Dominance (1/3 width) */} +
+ +
+
+ + {/* Analytics Section 1: Market Sentiment */} +
+ {/* Fear & Greed Index */} +
+ +
+ + {/* Altcoin Season Index */} +
+ +
+ + {/* Market Index */} +
+ +
+
+ + {/* Analytics Section 2: Market Activity */} +
+ {/* Whale Alerts */} +
+ +
+ + {/* Trending Coins */} +
+ +
+
+ + {/* Analytics Section 3: Market Metrics */} +
+ + + +
+ + {/* Analytics Section 4: DeFi & NFT Metrics */} +
+ + +
+ + {/* Analytics Section 5: Additional Market Stats */} +
+ + + + + Market Statistics + + + + +
+
Active Cryptocurrencies
+
+ {marketData?.active_cryptocurrencies?.toLocaleString() || '0'} +
+
+
+
Active Markets
+
+ {marketData?.markets?.toLocaleString() || '0'} +
+
+
+
BTC Market Cap
+
+ {formatNumber((marketData?.total_market_cap?.usd || 0) * ((marketData?.market_cap_percentage?.btc || 0) / 100))} +
+
+
+
ETH Market Cap
+
+ {formatNumber((marketData?.total_market_cap?.usd || 0) * ((marketData?.market_cap_percentage?.eth || 0) / 100))} +
+
+
+
+
+ +
+ +
+ View Detailed Price Table + +
+ +
+ + ) : ( +
+

+ Unable to load market data. Please try again later. +

+
+ )} +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index ef3d4f3..0b912ea 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,19 +1,36 @@ 'use client'; - import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; import Image from 'next/image'; +import { motion } from 'framer-motion'; import { FaFacebookF, FaGithub, FaLinkedinIn } from 'react-icons/fa'; import ParticlesBackground from '@/components/ParticlesBackground'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { ArrowRight, TrendingUp, Wallet, Box } from 'lucide-react'; +import DemoShowcase from '@/components/home/DemoShowcase'; import EthPriceLine from '@/components/home/EthPriceLine'; import CryptoPathExplorer from '@/components/home/CryptoExplorer'; import TrendingProjects from '@/components/home/TrendingProjects'; import TrendingNFTCollections from '@/components/home/TrendingNFTs'; +import PartnerBar from '@/components/PartnerBar'; import FAQ from './FAQ'; import AOS from 'aos'; import 'aos/dist/aos.css'; -import PartnerBar from '@/components/PartnerBar'; import toast from 'react-hot-toast'; +// FeatureCardProps interface should include language +interface FeatureCardProps { + icon: React.ReactNode; + title: string; + description: string; + href: string; + imageUrl?: string; + delay: number; + language: Language; // Add language prop +} + +// Tab types type Tab = 'sgd' | 'web3'; // Language types @@ -66,7 +83,22 @@ const translations = { pleaseEnterEmail: "Please enter your email address", pleaseEnterValidEmail: "Please enter a valid email address", errorOccurred: "An error occurred while registering!", - registrationSuccessful: "Registration successful! Please check your email." + registrationSuccessful: "Registration successful! Please check your email.", + exploreFuture: "Explore the Future of Blockchain", + cryptoPathProvides: "CryptoPath provides powerful tools to navigate the decentralized landscape. Track transactions, explore NFTs, and gain insights into the crypto market.", + exploreMarkets: "Explore Markets", + discoverNFTs: "Discover NFTs", + powerfulTools: "Powerful Blockchain Tools", + exploreFeatureRich: "Explore our feature-rich platform designed for both beginners and experienced crypto enthusiasts", + marketAnalytics: "Market Analytics", + marketAnalyticsDesc: "Real-time price data, market trends, and comprehensive analysis of cryptocurrencies.", + nftMarketplace: "NFT Marketplace", + nftMarketplaceDesc: "Buy, sell, and create NFTs on the PATH token ecosystem, or explore NFT collections across the blockchain.", + transactionExplorer: "Transaction Explorer", + transactionExplorerDesc: "Track and analyze blockchain transactions with detailed visualizations and insights.", + getStarted: "Get Started", + tryDemo: "Try Demo", + explore: "Explore" }, vi: { vietnamPremierCrypto: "Nền Tảng Khám Phá Blockchain Hàng Đầu Việt Nam", @@ -113,7 +145,22 @@ const translations = { pleaseEnterEmail: "Vui lòng nhập địa chỉ email của bạn", pleaseEnterValidEmail: "Vui lòng nhập địa chỉ email hợp lệ", errorOccurred: "Đã xảy ra lỗi khi đăng ký!", - registrationSuccessful: "Đăng ký thành công! Vui lòng kiểm tra email của bạn." + registrationSuccessful: "Đăng ký thành công! Vui lòng kiểm tra email của bạn.", + exploreFuture: "Khám Phá Tương Lai Của Blockchain", + cryptoPathProvides: "CryptoPath cung cấp các công cụ mạnh mẽ để điều hướng trong không gian phi tập trung. Theo dõi giao dịch, khám phá NFT và nhận thông tin chi tiết về thị trường tiền điện tử.", + exploreMarkets: "Khám Phá Thị Trường", + discoverNFTs: "Khám Phá NFTs", + powerfulTools: "Công Cụ Blockchain Mạnh Mẽ", + exploreFeatureRich: "Khám phá nền tảng đầy tính năng của chúng tôi được thiết kế cho cả người mới bắt đầu và những người đam mê tiền điện tử có kinh nghiệm", + marketAnalytics: "Phân Tích Thị Trường", + marketAnalyticsDesc: "Dữ liệu giá thời gian thực, xu hướng thị trường và phân tích toàn diện về tiền điện tử.", + nftMarketplace: "Thị Trường NFT", + nftMarketplaceDesc: "Mua, bán và tạo NFT trên hệ sinh thái token PATH, hoặc khám phá các bộ sưu tập NFT trên blockchain.", + transactionExplorer: "Khám Phá Giao Dịch", + transactionExplorerDesc: "Theo dõi và phân tích các giao dịch blockchain với hình ảnh trực quan và chi tiết chi tiết.", + getStarted: "Bắt Đầu", + tryDemo: "Dùng Thử", + explore: "Khám Phá" } }; @@ -147,7 +194,7 @@ const teamMembers = [ }, ]; -const HomePage = () => { +const LandingPage = () => { const [activeTab, setActiveTab] = useState('sgd'); const [email, setEmail] = useState(''); const [emailError, setEmailError] = useState(''); @@ -155,10 +202,13 @@ const HomePage = () => { const [isSuccess, setIsSuccess] = useState(false); const [language, setLanguage] = useState('en'); + // Declare t only once + const t = translations[language]; + useEffect(() => { AOS.init({ - duration: 1000, // Animation duration (in ms) - once: true, // Whether animation should happen only once while scrolling down + duration: 1000, + once: true, }); // Initialize language based on browser preference @@ -192,13 +242,13 @@ const HomePage = () => { // Email validation with language-specific messages if (!email) { - setEmailError(translations[language].pleaseEnterEmail); + setEmailError(t.pleaseEnterEmail); // Use t directly return; } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { - setEmailError(translations[language].pleaseEnterValidEmail); + setEmailError(t.pleaseEnterValidEmail); // Use t directly return; } @@ -217,7 +267,7 @@ const HomePage = () => { const data = await response.json(); if (!response.ok) { - throw new Error(data.message || translations[language].errorOccurred); + throw new Error(data.message || t.errorOccurred); // Use t directly } // Handle success @@ -225,60 +275,78 @@ const HomePage = () => { setIsSuccess(true); // Success message based on language - toast.success(translations[language].registrationSuccessful); + toast.success(t.registrationSuccessful); // Use t directly } catch (error) { console.error(language === 'en' ? 'Error:' : 'Lỗi:', error); - toast.error(error instanceof Error ? error.message : translations[language].errorOccurred); + toast.error(error instanceof Error ? error.message : t.errorOccurred); // Use t directly } finally { setIsSubmitting(false); } }; - const t = translations[language]; - return (
- - -
- {/* Description Section */} -
+ + + {/* Hero Section */} +
+ {/* CryptoPath Explorer Section */} +
-

- {t.vietnamPremierCrypto} -

-

- {t.joinAllInOne}{t.appInVietnam} -

-
-
- - {emailError &&

{emailError}

} - {isSuccess &&

- {t.signUpSuccess} -

} -
- +
+
+ + + - + + + + + +
@@ -291,324 +359,272 @@ const HomePage = () => {
- - - {/* Trade Like a Pro Section */} -
-
-

{t.tradeLikePro}{t.aPro}

-

- {t.getLowestFees} -

-
-
-
- -
-
-
-
-
+ + {/* Trending Projects & Partner Bar */} + + - {/* Dynamic Content Section */} -
-
-
- CryptoPath Content -
-
-

{t.oneApplication}{t.infinitePotential}

-

- {activeTab === 'sgd' ? t.exploreNFTMarketplace : t.exploreDecentralized} -

-
- - -
-
-
-
- - {/* Evolution Illustration Section */} + {/* Demo Showcase Section */} + + + {/* Trade Like a Pro Section */} +
-

{t.accompanyingYou}{t.everyStep}

-

- {t.fromCryptoTransactions} -
- {t.believeInYourself} +

{t.tradeLikePro}{t.aPro}

+

+ {t.getLowestFees}

-
-
-
+
- {/* Meet the Team */} -
-
-

- {t.meetTheTeam}{t.team} -

-

- {t.willingToListen} + {/* Features Section */} +

+
+ +

{t.powerfulTools}

+

+ {t.exploreFeatureRich}

+
+ +
+ } + title={t.marketAnalytics} + description={t.marketAnalyticsDesc} + href="/pricetable" + imageUrl="/feature-market.png" + delay={0.1} + language={language} + /> + + } + title={t.nftMarketplace} + description={t.nftMarketplaceDesc} + href="/NFT" + imageUrl="/feature-nft.png" + delay={0.2} + language={language} + /> + + } + title={t.transactionExplorer} + description={t.transactionExplorerDesc} + href="/search" + imageUrl="/feature-transaction.png" + delay={0.3} + language={language} + /> +
+
+
+ + {/* Dynamic Content Section */} +
+
+
+ CryptoPath Content
+
+

{t.oneApplication}{t.infinitePotential}

+

+ {activeTab === 'sgd' ? t.exploreNFTMarketplace : t.exploreDecentralized} +

+
+ + +
+
+
+
-
-
- {teamMembers.map((member) => ( -
- + {/* Trending NFTs Section */} + + + {/* Evolution Illustration Section */} +
+

{t.accompanyingYou}{t.everyStep}

+

+ {t.fromCryptoTransactions} +
+ {t.believeInYourself} +

+
+
+
+ +
+
+
+ + {/* Meet the Team */} +
+
+

+ {t.meetTheTeam}{t.team} +

+

+ {t.willingToListen} +

+
+ +
+
+ {teamMembers.map((member) => ( +
{/* Profile Image */}
{`${member.name}'s
- - {/* Name & Role */} -

{member.name}

-

{member.role}

-

{member.bio}

- - {/* Social Icons */} -
- {member.facebook && ( - - - - )} - {member.github && ( - - - - )} - {member.linkedin && ( - - - - )} -
+ {/* Name and Role */} +
+

{member.name}

+

{member.role}

+
+ {/* Bio */} +

{member.bio}

+ {/* Social Links */} + - ))} -
-
-
- - {/* CryptoPath Introduction and Trusted Leaders Section */} -
-
-

{t.whatIsCryptoPath}{t.cryptoPath}

-

- {t.hearFromTopIndustry} -
- {t.whyCryptoPathIsFavorite} -

- -
-
- {/* Video 1: YouTube Embed */} -
- -
-

{t.whatIsCryptocurrency}

-

{t.explainingNewCurrency}

-
- - {/* Video 2: YouTube Embed */} -
- -
-

{t.redefiningSystem}

-

{t.welcomeToWeb3}

+ ))} +
+
+
+ + {/* FAQ Section */} + + + {/* CTA Section */} +
+
+
+
+
+

{t.readyToStart}

+

+ {t.joinThousands} +

-
- - {/* Video 3: YouTube Embed */} -
- -
-

{t.whatIsBlockchain}

-

{t.understandBlockchain}

+ +
+ + + + + +
+
+
+ ); +}; - {/* Trusted Leaders Section */} -
-
-

- {t.trustedBy} {t.industryLeaders} -

-
-
-
- Facebook -

Facebook

-
-
- Apple -

Apple

-
-
- Amazon -

Amazon

-
-
- Netflix -

Netflix

-
-
- Google -

Google

-
-
- -
-
- Minh Duy Nguyen -
-
-

- {t.testimonialText} -

-

Nguyen Minh Duy

-

{t.founderOf}

-
+// FeatureCard component updated to use language prop +const FeatureCard = ({ icon, title, description, href, imageUrl, delay, language }: FeatureCardProps) => { + const [isHovered, setIsHovered] = useState(false); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + +
+ {imageUrl && ( +
+
+
+ )}
-
- - {/* CTA Section (New) */} -
-
-

{t.readyToStart}

-

- {t.joinThousands} -

-
- - + + +
{icon}
+

{title}

+

{description}

+ +
+ {translations[language].explore} {/* Use language prop */} +
-
-
- - {/* Insert FAQ component here - Pass language to FAQ component */} - -
-
+ + + + ); }; -export default HomePage; \ No newline at end of file +export default LandingPage; \ No newline at end of file diff --git a/app/portfolio/page.tsx b/app/portfolio/page.tsx new file mode 100644 index 0000000..33a37f3 --- /dev/null +++ b/app/portfolio/page.tsx @@ -0,0 +1,231 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import BalanceCard from "@/components/portfolio/BalanceCard"; +import TokensCard from "@/components/portfolio/TokenCard"; // Giả sử là TokensCard +import NFTsCard from "@/components/portfolio/NFTsCard"; +import HistoryChart from "@/components/portfolio/HistoryCard"; // Giả sử là HistoryChart +import AllocationChart from "@/components/portfolio/Allocation"; +import ActivityTable from "@/components/portfolio/ActivityTable"; +import { getWalletData, WalletData } from "@/components/portfolio_service/alchemyService"; +import { toast } from "@/components/ui/use-toast"; +import { supabase } from "@/src/integrations/supabase/client"; +import { ethers } from "ethers"; +import ParticlesBackground from "@/components/ParticlesBackground"; + +const keyframeStyles = ` + @keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } } + @keyframes shine { 0% { transform: rotate(30deg) translate(-100%, -100%); } 100% { transform: rotate(30deg) translate(100%, 100%); } } + @keyframes pulse-amber { 0%, 100% { box-shadow: 0 0 10px 2px rgba(246, 179, 85, 0.4); } 50% { box-shadow: 0 0 20px 4px rgba(246, 179, 85, 0.6); } } + @keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } +`; + +interface Wallet { + address: string; + is_default: boolean; +} + +interface Profile { + wallets: Wallet[]; +} + +const PortfolioPage = () => { + const [walletAddress, setWalletAddress] = useState(""); + const [walletData, setWalletData] = useState({ + balance: "0", + tokens: [], + nfts: [], + transactions: [], + }); + const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); + + // Thêm keyframes vào head + useEffect(() => { + const styleElement = document.createElement("style"); + styleElement.innerHTML = keyframeStyles; + document.head.appendChild(styleElement); + + return () => { + if (styleElement.parentNode) { + styleElement.parentNode.removeChild(styleElement); + } + }; + }, []); + + // Lấy địa chỉ ví từ Supabase và dữ liệu từ Alchemy + useEffect(() => { + const fetchWalletAddress = async () => { + setIsLoading(true); + try { + const { + data: { session }, + error: sessionError, + } = await supabase.auth.getSession(); + if (sessionError || !session) { + toast({ + title: "Not logged in", + description: "Please log in to view your portfolio.", + variant: "destructive", + }); + router.push("/login"); + return; + } + + const userId = session.user.id; + + const { data: profileData, error: profileError } = await supabase + .from("profiles") + .select("wallets") + .eq("id", userId) + .single(); + + console.log("Profile data:", profileData, "Error:", profileError); + + if (profileError || !profileData) { + throw new Error("No profile found or query error."); + } + + const wallets = profileData.wallets as Wallet[]; + if (!wallets || !Array.isArray(wallets) || wallets.length === 0) { + console.warn("No valid wallets found, redirecting to settings."); + router.push("/settings"); + return; + } + + const defaultWallet = wallets.find((wallet) => wallet.is_default); + if (!defaultWallet || !defaultWallet.address) { + console.warn("No default wallet address found, redirecting to settings."); + router.push("/settings"); + return; + } + + const address = defaultWallet.address; + if (!ethers.utils.isAddress(address)) { + console.error("Invalid Ethereum address:", address); + throw new Error("Invalid wallet address format."); + } + + setWalletAddress(address); + console.log("Fetching data for address:", address); + + const portfolioData = await getWalletData(address); + console.log("Portfolio data:", portfolioData); + setWalletData(portfolioData); + toast({ + title: "Portfolio loaded", + description: `Data for wallet ${address.slice(0, 6)}...${address.slice(-4)} has been loaded.`, + }); + } catch (error: any) { + console.error("Error fetching wallet data:", error.message); + toast({ + title: "Error", + description: "Unable to load portfolio data. Please check your profile settings.", + variant: "destructive", + }); + router.push("/settings"); + } finally { + setIsLoading(false); + } + }; + + fetchWalletAddress(); + }, [router]); + + // Hiệu ứng animation khi scroll + useEffect(() => { + const animateElements = () => { + const elements = document.querySelectorAll(".opacity-0"); + elements.forEach((element) => { + const rect = element.getBoundingClientRect(); + if (rect.top < window.innerHeight) { + (element as HTMLElement).style.opacity = "1"; + (element as HTMLElement).style.transform = "translateY(0)"; + } + }); + }; + window.addEventListener("scroll", animateElements); + animateElements(); + return () => window.removeEventListener("scroll", animateElements); + }, []); + + return ( +
+ {/* Bọc ParticlesBackground */} + + + {/* Nội dung chính */} +
+
+

+ Your Wallet Portfolio +

+ {walletAddress && ( +

+ Viewing portfolio for: {walletAddress.slice(0, 6)}...{walletAddress.slice(-4)} +

+ )} +
+ + {walletAddress && ( +
+
+ +
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+ )} + +
+
+ Wallet Portfolio Scanner • Powered by Alchemy +
+
+
+
+
+ ); +}; + +export default PortfolioPage; \ No newline at end of file diff --git a/components/Header.tsx b/components/Header.tsx index 6e2e689..f988fd6 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -12,7 +12,6 @@ import { supabase } from "@/src/integrations/supabase/client"; import { toast } from "sonner"; import type { AuthChangeEvent, Session } from "@supabase/supabase-js"; -// Add this helper function at the top of your component or in a utils file const shortenAddress = (address: string): string => { if (!address) return ''; return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; @@ -33,11 +32,10 @@ const Header = () => { const dropdownRef = useRef(null); const { profile } = useSettings(); - // Fetch and sync user state with Supabase Auth + // Auth handling useEffect(() => { const fetchUser = async () => { const { data: { session } } = await supabase.auth.getSession(); - if (session) { const user = session.user; setCurrentUser({ @@ -45,8 +43,6 @@ const Header = () => { email: user.email, name: user.user_metadata?.full_name || user.email?.split("@")[0], }); - - // Store user info in localStorage for other components localStorage.setItem('currentUser', JSON.stringify({ id: user.id, email: user.email, @@ -62,7 +58,6 @@ const Header = () => { fetchUser(); - // Listen for auth state changes const { data: authListener } = supabase.auth.onAuthStateChange( (event: AuthChangeEvent, session: Session | null) => { if (event === "SIGNED_IN" && session) { @@ -72,8 +67,6 @@ const Header = () => { email: user.email, name: user.user_metadata?.full_name || user.email?.split("@")[0], }); - - // Store user info in localStorage for other components localStorage.setItem('currentUser', JSON.stringify({ id: user.id, email: user.email, @@ -93,7 +86,7 @@ const Header = () => { }; }, []); - // Handle dropdown click outside + // Dropdown handling useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { @@ -110,12 +103,12 @@ const Header = () => { setIsLoading(true); try { - await new Promise((resolve) => setTimeout(resolve, 2500)); // Simulated delay - if (searchType === "onchain") { - router.push(`/search/?address=${encodeURIComponent(address)}`); - } else { - router.push(`/search-offchain/?address=${encodeURIComponent(address)}`); - } + await new Promise((resolve) => setTimeout(resolve, 2500)); + router.push( + searchType === "onchain" + ? `/search/?address=${encodeURIComponent(address)}` + : `/search-offchain/?address=${encodeURIComponent(address)}` + ); } catch (error) { console.error("Search error:", error); } finally { @@ -128,15 +121,16 @@ const Header = () => { setDropdownOpen(false); }; - const clearAddress = () => setAddress(""); - - const handleSearchIconClick = () => router.push("/search"); + const handlePortfolioClick = () => { + router.push("/portfolio"); + setDropdownOpen(false); + }; const handleLogout = async () => { try { const { error } = await supabase.auth.signOut(); if (error) throw error; - localStorage.removeItem("currentUser"); // Clean up if used + localStorage.removeItem("currentUser"); setCurrentUser(null); setDropdownOpen(false); toast.success("Logged out successfully"); @@ -150,6 +144,9 @@ const Header = () => { } }; + const clearAddress = () => setAddress(""); + const handleSearchIconClick = () => router.push("/search"); + const displayName = profile?.username && profile.username !== "User" ? profile.username @@ -158,8 +155,8 @@ const Header = () => { return ( <>
-
-

+
+

{

-

)}
) : ( - - Login - + Login )} {isOpen && ( -
-
) : ( - - Login - + Login )}
@@ -403,4 +354,4 @@ const Header = () => { ); }; -export default Header; +export default Header; \ No newline at end of file diff --git a/components/NFT/FeaturedCollections.tsx b/components/NFT/FeaturedCollections.tsx new file mode 100644 index 0000000..64007dd --- /dev/null +++ b/components/NFT/FeaturedCollections.tsx @@ -0,0 +1,181 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ExternalLink, Sparkles, Zap, TrendingUp, Users } from 'lucide-react'; + +interface Collection { + id: string; + name: string; + description: string; + image: string; + bannerImage?: string; + floorPrice?: string; + volume24h?: string; + totalItems?: number; + owners?: number; + verified?: boolean; + category: string; +} + +interface FeaturedCollectionsProps { + collections: Collection[]; +} + +export default function FeaturedCollections({ collections = [] }: FeaturedCollectionsProps) { + // If no real data, use sample data + const sampleCollections: Collection[] = collections.length > 0 ? collections : [ + { + id: '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.', + image: 'https://i.seadn.io/gae/Ju9CkWtV-1Okvf45wo8UctR-M9He2PjILP0oOvxE89AyiPPGtrR3gysu1Zgy0hjd2xKIgjJJtWIc0ybj4Vd7wv8t3pxDGHoJBzDB?auto=format&dpr=1&w=1000', + bannerImage: 'https://i.seadn.io/gae/i5dYZRkVCUK97bfprQ3WXyrT9BnLSZtVKGJlKQ919uaUB0sxbngVCioaiyu9r6snqfi2aaTyIvv6DHm4m2R3y7hMajbsv14pSZK8mhs?auto=format&dpr=1&w=3840', + floorPrice: '30.5', + volume24h: '450.23', + totalItems: 10000, + owners: 6350, + verified: true, + category: 'Art' + }, + { + id: '0x60e4d786628fea6478f785a6d7e704777c86a7c6', + name: 'Mutant Ape Yacht Club', + description: 'The MUTANT APE YACHT CLUB is a collection of up to 20,000 Mutant Apes that can only be created by exposing an existing Bored Ape to a vial of MUTANT SERUM.', + image: 'https://i.seadn.io/gae/lHexKRMpw-aoSyB1WdFBff5yfANLReFxHzt1DOj_sg7mS14yARpuvYcUtsyyx-Nkpk6WTcUPF6rLh2D4Xw?auto=format&dpr=1&w=1000', + floorPrice: '10.2', + volume24h: '250.15', + totalItems: 19423, + owners: 12340, + verified: true, + category: 'Art' + }, + { + id: '0xed5af388653567af2f388e6224dc7c4b3241c544', + name: 'Azuki', + description: 'Azuki starts with a collection of 10,000 avatars that give you membership access to The Garden: a corner of the internet where artists, builders, and web3 enthusiasts meet to create a decentralized future.', + image: 'https://i.seadn.io/gae/H8jOCJuQokNqGBpkBN5wk1oZwO7LM8bNnrHCaekV2nKjnCqw6UB5oaH8XyNeBDj6bA_n1mjejzhFQUP3O1NfjFLHr3FOaeHcTOOT?auto=format&dpr=1&w=1000', + floorPrice: '8.75', + volume24h: '175.45', + totalItems: 10000, + owners: 5120, + verified: true, + category: 'PFP' + } + ]; + + return ( +
+
+

+ + Featured Collections +

+ + + +
+ +
+ {sampleCollections.map((collection) => ( + +
+ {collection.bannerImage ? ( + {`${collection.name} + ) : ( +
+ )} +
+
+ {collection.name} +
+
+ {collection.verified && ( +
+ + Verified + +
+ )} +
+ + + + + {collection.name} + + + + {collection.category} + + + + +

+ {collection.description} +

+ +
+
+

Floor Price

+

+ {collection.floorPrice} ETH +

+
+
+

Volume (24h)

+

+ {collection.volume24h} ETH + {parseFloat(collection.volume24h || '0') > 200 && ( + + )} +

+
+
+
+ + +
+ + {collection.owners?.toLocaleString()} owners +
+
+ {collection.totalItems?.toLocaleString()} items +
+ + + +
+
+ ))} +
+ +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/components/NFT/ListForm.tsx b/components/NFT/ListForm.tsx index c7a9a2d..ae13d9b 100644 --- a/components/NFT/ListForm.tsx +++ b/components/NFT/ListForm.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, FormEvent } from 'react'; export default function ListForm({ onSubmit, @@ -8,31 +8,77 @@ export default function ListForm({ onCancel: () => void; }) { const [price, setPrice] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + + // Clear previous error + setError(''); + + // Validate price + if (!price) { + setError('Please enter a price'); + return; + } + + const numPrice = parseFloat(price); + if (isNaN(numPrice)) { + setError('Please enter a valid number'); + return; + } + + if (numPrice <= 0) { + setError('Price must be greater than 0'); + return; + } + + if (numPrice > 1000000) { + setError('Price cannot exceed 1,000,000 PATH'); + return; + } + + // Submit if validation passes + onSubmit(price); + }; return ( -
- setPrice(e.target.value)} - placeholder="Enter price in PATH" - className="w-full p-2 bg-gray-700 rounded text-sm" - /> +
+
+ { + setPrice(e.target.value); + setError(''); // Clear error when input changes + }} + placeholder="Enter price in PATH" + className={`w-full p-2 bg-gray-700 rounded text-sm ${ + error ? 'border border-red-500' : '' + }`} + /> + {error && ( +

{error}

+ )} +
-
+ ); } \ No newline at end of file diff --git a/components/NFT/MintForm.tsx b/components/NFT/MintForm.tsx index cfeaeb1..bcab115 100644 --- a/components/NFT/MintForm.tsx +++ b/components/NFT/MintForm.tsx @@ -16,30 +16,109 @@ export default function MintForm({ const [tokenURI, setTokenURI] = useState(''); const [recipient, setRecipient] = useState(''); const [isWhitelisted, setIsWhitelisted] = useState(false); + const [validatingURI, setValidatingURI] = useState(false); + const [uriError, setUriError] = useState(null); // Validate Ethereum address const isValidAddress = (address: string) => utils.isAddress(address); - // Validate IPFS/HTTP URI - const isValidURI = (uri: string) => uri.startsWith('ipfs://') || uri.startsWith('https://'); + // Validate metadata format + const validateMetadata = async (uri: string): Promise => { + try { + setValidatingURI(true); + setUriError(null); + + if (!uri.startsWith('ipfs://') && !uri.startsWith('https://')) { + setUriError('URI must start with ipfs:// or https://'); + return false; + } + + // For IPFS URIs, only validate the format + if (uri.startsWith('ipfs://')) { + const cid = uri.replace('ipfs://', '').split('/')[0]; + if (!cid || cid.length < 32) { + setUriError('Invalid IPFS CID format'); + return false; + } + return true; + } + + // For HTTP URIs, validate the metadata format + const response = await fetch(uri); + if (!response.ok) { + setUriError('Failed to fetch metadata'); + return false; + } + + const metadata = await response.json(); + if (!metadata.name || typeof metadata.name !== 'string') { + setUriError('Metadata must include a name property'); + return false; + } + if (!metadata.image || typeof metadata.image !== 'string') { + setUriError('Metadata must include an image property'); + return false; + } + if (!metadata.image.startsWith('ipfs://') && !metadata.image.startsWith('https://')) { + setUriError('Image URI must start with ipfs:// or https://'); + return false; + } + + return true; + } catch (error) { + setUriError(error instanceof Error ? error.message : 'Invalid metadata format'); + return false; + } finally { + setValidatingURI(false); + } + }; + + // Validate URI when it changes + useEffect(() => { + const timer = setTimeout(() => { + if (tokenURI) { + validateMetadata(tokenURI); + } else { + setUriError(null); + } + }, 500); + + return () => clearTimeout(timer); + }, [tokenURI]); // Check whitelist status when recipient changes useEffect(() => { const verifyWhitelist = async () => { if (isValidAddress(recipient)) { - const status = await checkWhitelist(recipient); - setIsWhitelisted(status); + try { + const status = await checkWhitelist(recipient); + setIsWhitelisted(status); + } catch (error) { + console.error('Failed to check whitelist status:', error); + setIsWhitelisted(false); + } } else { setIsWhitelisted(false); } }; - verifyWhitelist(); + + if (recipient) { + verifyWhitelist(); + } }, [recipient, checkWhitelist]); // Combined disable conditions const isDisabled = processing || !isValidAddress(recipient) || - !isValidURI(tokenURI); + validatingURI || + !!uriError || + !tokenURI; + + const handleSubmit = async () => { + if (await validateMetadata(tokenURI)) { + onSubmit(recipient, tokenURI); + } + }; return (
@@ -109,15 +188,23 @@ export default function MintForm({ value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="0x..." - className="w-full px-4 py-3 bg-gray-800 border-2 border-gray-700 rounded-xl - focus:border-orange-400 focus:ring-4 focus:ring-orange-400/20 - transition-all placeholder-gray-500 text-white font-mono text-sm" + className={`w-full px-4 py-3 bg-gray-800 border-2 rounded-xl + transition-all placeholder-gray-500 text-white font-mono text-sm + ${!isValidAddress(recipient) && recipient + ? 'border-red-500 focus:ring-red-500/20' + : 'border-gray-700 focus:border-orange-400 focus:ring-4 focus:ring-orange-400/20' + }`} /> - {!isValidAddress(recipient) && recipient !== '' && ( + {!isValidAddress(recipient) && recipient && (

⚠ Invalid BSC address

- )} + )} + {isValidAddress(recipient) && !isWhitelisted && ( +

+ ⚠ Address is not whitelisted +

+ )}
{/* Metadata URI Input */} @@ -128,13 +215,22 @@ export default function MintForm({ value={tokenURI} onChange={(e) => setTokenURI(e.target.value)} placeholder="ipfs://Qm... or https://" - className="w-full px-4 py-3 bg-gray-800 border-2 border-gray-700 rounded-xl - focus:border-orange-400 focus:ring-4 focus:ring-orange-400/20 - transition-all placeholder-gray-500 text-white text-sm" + className={`w-full px-4 py-3 bg-gray-800 border-2 rounded-xl + transition-all placeholder-gray-500 text-white text-sm + ${uriError + ? 'border-red-500 focus:ring-red-500/20' + : 'border-gray-700 focus:border-orange-400 focus:ring-4 focus:ring-orange-400/20' + }`} /> - {!isValidURI(tokenURI) && tokenURI !== '' && ( + {validatingURI && ( +

+ + Validating metadata... +

+ )} + {uriError && (

- ⚠ URI must start with ipfs:// or https:// + ⚠ {uriError}

)}
@@ -142,7 +238,7 @@ export default function MintForm({ {/* Mint Button */} + + + {/* Wallet Section */} +
+ {hasWallet && ( +
+ + {pathBalance} + + PATH +
+ )} + + +
+
+ + + + {/* Category Filters */} + {activeSection === 'marketplace' ? ( +
+ All NFTs + Art + Collectibles + Gaming + Membership + Virtual Land + Music + Photography + Sports + Utility +
+ ) : ( +
+ All Collections + Verified + Art + Gaming + PFP + Photography + Music + Metaverse +
+ )} + + {/* Feature Cards */} +
+ } + title="PATH NFT Marketplace" + description="Buy, sell, and create NFTs on the PATH token ecosystem. List your digital assets and trade with other users." + href="/NFT" + color="border-blue-500/30" + delay={0.1} + isActive={activeSection === 'marketplace'} + image="/Img/Web3.webp" + /> + + } + title="NFT Collection Scanner" + description="Explore NFT collections across all EVM-based blockchains. Browse popular collections or connect your wallet to view your own NFTs." + href="/NFT/collection" + color="border-purple-500/30" + delay={0.2} + isActive={activeSection === 'collections'} + image="/Img/Web3.webp" + /> +
+ + {/* Tutorial Section */} +
+
+
+

New to NFTs?

+

Learn how to connect your wallet and explore the world of NFTs

+
+ +
+
+
+ ); +} diff --git a/components/NFT/PriceChart.tsx b/components/NFT/PriceChart.tsx new file mode 100644 index 0000000..6f30a9a --- /dev/null +++ b/components/NFT/PriceChart.tsx @@ -0,0 +1,146 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +interface PricePoint { + date: string; + price: number; +} + +interface PriceChartProps { + data: PricePoint[]; + tokenId?: string; +} + +export default function PriceChart({ data, tokenId }: PriceChartProps) { + const [timeRange, setTimeRange] = useState<'1h' | '24h' | '7d' | '30d'>('24h'); + const [chartData, setChartData] = useState([]); + + // Filter data based on selected time range + useEffect(() => { + if (!data || data.length === 0) { + setChartData([ + { date: '00:00', price: 100 }, + { date: '04:00', price: 120 }, + { date: '08:00', price: 90 }, + { date: '12:00', price: 150 }, + { date: '16:00', price: 180 }, + { date: '20:00', price: 200 }, + { date: '24:00', price: 160 }, + ]); // Placeholder data + return; + } + + const now = new Date(); + const filterDate = new Date(); + + switch (timeRange) { + case '1h': + filterDate.setHours(now.getHours() - 1); + break; + case '24h': + filterDate.setDate(now.getDate() - 1); + break; + case '7d': + filterDate.setDate(now.getDate() - 7); + break; + case '30d': + filterDate.setDate(now.getDate() - 30); + break; + } + + const filtered = data.filter((point) => { + const pointDate = new Date(point.date); + return pointDate >= filterDate; + }); + + setChartData(filtered); + }, [data, timeRange]); + + const formatYAxis = (value: number) => { + return `${value} PATH`; + }; + + // Calculate price change + const priceChange = chartData.length >= 2 + ? ((chartData[chartData.length - 1].price - chartData[0].price) / chartData[0].price) * 100 + : 0; + + const isPriceUp = priceChange >= 0; + + return ( + + +
+ + {tokenId ? `NFT #${tokenId} Price History` : 'Market Price Trends'} + + setTimeRange(v as any)}> + + 1H + 24H + 7D + 30D + + +
+
+ +
+
+

Current Price

+

+ {chartData.length > 0 ? chartData[chartData.length - 1].price : 0} PATH +

+
+
+ {priceChange.toFixed(2)}% +
+
+ +
+ + + + + + [`${value} PATH`, 'Price']} + /> + + + +
+
+
+ ); +} diff --git a/components/NFT/TradingHistory.tsx b/components/NFT/TradingHistory.tsx new file mode 100644 index 0000000..2c30d9d --- /dev/null +++ b/components/NFT/TradingHistory.tsx @@ -0,0 +1,273 @@ +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ExternalLink, TrendingUp, ArrowUpRight, ShoppingCart, AlertTriangle } from 'lucide-react'; + +type EventType = 'Sale' | 'Transfer' | 'Mint' | 'List'; + +interface Trade { + id: string; + event: EventType; + tokenId: string; + from: string; + to: string; + price?: string; + timestamp: string; + txHash: string; +} + +interface TradingHistoryProps { + trades: Trade[]; + tokenId?: string; + loadMore?: () => void; + hasMore?: boolean; + loading?: boolean; +} + +type TabType = 'all' | 'sales' | 'transfers' | 'mints' | 'lists'; + +export default function TradingHistory({ + trades = [], + tokenId, + loadMore, + hasMore = false, + loading = false +}: TradingHistoryProps) { + const [activeTab, setActiveTab] = useState('all'); + const [error, setError] = useState(null); + + // Filter trades based on active tab + const filteredTrades = trades.filter(trade => { + if (activeTab === 'all') return true; + return trade.event.toLowerCase() === activeTab.slice(0, -1); + }); + + // Format addresses to be more readable + const formatAddress = (address: string) => { + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; + }; + + // Format dates to be more readable + const formatDate = (dateStr: string) => { + try { + const date = new Date(dateStr); + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(date); + } catch (error) { + console.error('Invalid date format:', error); + return 'Invalid date'; + } + }; + + // Format price with proper decimal places + const formatPrice = (price?: string) => { + if (!price) return null; + try { + const numPrice = parseFloat(price); + return numPrice.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 4 + }); + } catch (error) { + console.error('Invalid price format:', error); + return price; + } + }; + + // Event badge styling with hover effects + const getEventBadge = (event: EventType) => { + const styles = { + Sale: 'bg-green-600 hover:bg-green-700', + Transfer: 'bg-blue-600 hover:bg-blue-700', + Mint: 'bg-purple-600 hover:bg-purple-700', + List: 'bg-orange-600 hover:bg-orange-700' + }; + + return ( + + {event} + + ); + }; + + const handleTabChange = (value: string) => { + setActiveTab(value as TabType); + }; + + return ( + + + + + {tokenId ? `NFT #${tokenId} Trading History` : 'Recent Transactions'} + + + + + + All + Sales + Transfers + Mints + Lists + + + + {error ? ( +
+ +

{error}

+
+ ) : ( + + + + Event + {!tokenId && Token ID} + Price + From + To + Date + + + + + {filteredTrades.map((trade) => ( + + + {getEventBadge(trade.event)} + + {!tokenId && ( + + + #{trade.tokenId} + + + + )} + + {trade.price ? ( +
+ {formatPrice(trade.price)} + PATH +
+ ) : ( + -- + )} +
+ +
+ + + {trade.from.substring(0, 2)} + + + {formatAddress(trade.from)} + +
+
+ +
+ + + {trade.to.substring(0, 2)} + + + {formatAddress(trade.to)} + +
+
+ + {formatDate(trade.timestamp)} + + + + + + +
+ ))} + + {loading && ( + + +
+
+ Loading transactions... +
+ + + )} + + {filteredTrades.length === 0 && !loading && ( + + +
+ +
+

No trading history available

+

+ {activeTab === 'all' + ? 'Be the first to make a transaction!' + : `No ${activeTab.slice(0, -1)} events found` + } +

+
+
+
+
+ )} + +
+ )} + + {hasMore && filteredTrades.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/components/NFT/WhitelistForm.tsx b/components/NFT/WhitelistForm.tsx index 85e8478..9cc88d9 100644 --- a/components/NFT/WhitelistForm.tsx +++ b/components/NFT/WhitelistForm.tsx @@ -1,32 +1,82 @@ -// components/NFT/WhitelistForm.tsx -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { utils } from 'ethers'; -import { Loader2, CheckCircle, XCircle } from 'lucide-react'; +import { Loader2, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; +import { toast } from '@/hooks/use-toast'; interface WhitelistFormProps { onSubmit: (address: string, status: boolean) => Promise; isOwner: boolean; } +interface Transaction { + address: string; + status: boolean; + timestamp: number; +} + export default function WhitelistForm({ onSubmit, isOwner }: WhitelistFormProps) { const [address, setAddress] = useState(''); const [processing, setProcessing] = useState(false); const [status, setStatus] = useState<'success' | 'error' | null>(null); + const [errorMessage, setErrorMessage] = useState(''); + const [previousTransactions, setPreviousTransactions] = useState([]); + + // Load previous transactions from localStorage + useEffect(() => { + const saved = localStorage.getItem('whitelistTransactions'); + if (saved) { + try { + const parsed = JSON.parse(saved); + setPreviousTransactions(parsed); + } catch (error) { + console.error('Failed to parse saved transactions:', error); + } + } + }, []); + + // Save transactions to localStorage + const saveTransaction = (address: string, status: boolean) => { + const transaction = { + address, + status, + timestamp: Date.now() + }; + const newTransactions = [transaction, ...previousTransactions].slice(0, 10); // Keep last 10 + setPreviousTransactions(newTransactions); + localStorage.setItem('whitelistTransactions', JSON.stringify(newTransactions)); + }; const isValidAddress = (addr: string) => utils.isAddress(addr); - const handleSubmit = async (status: boolean) => { - if (!isValidAddress(address)) return; + const handleSubmit = async (newStatus: boolean) => { + if (!isValidAddress(address)) { + setErrorMessage('Invalid address format'); + return; + } setProcessing(true); setStatus(null); + setErrorMessage(''); + try { - await onSubmit(address, status); + await onSubmit(address, newStatus); setStatus('success'); + saveTransaction(address, newStatus); + toast({ + title: 'Success', + description: `Address ${newStatus ? 'added to' : 'removed from'} whitelist`, + variant: 'default' + }); setAddress(''); } catch (error) { console.error(error); setStatus('error'); + setErrorMessage(error instanceof Error ? error.message : 'Operation failed'); + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Operation failed', + variant: 'destructive' + }); } finally { setProcessing(false); } @@ -49,12 +99,19 @@ export default function WhitelistForm({ onSubmit, isOwner }: WhitelistFormProps) value={address} onChange={(e) => setAddress(e.target.value)} placeholder="0x..." - className="w-full px-4 py-3 bg-gray-800 border-2 border-gray-700 rounded-xl - focus:border-orange-400 focus:ring-4 focus:ring-orange-400/20 - transition-all placeholder-gray-500 text-white font-mono text-sm" + className={`w-full px-4 py-3 bg-gray-800 border-2 rounded-xl + transition-all placeholder-gray-500 text-white font-mono text-sm + ${ + errorMessage + ? 'border-red-500 focus:ring-red-500/20' + : 'border-gray-700 focus:border-orange-400 focus:ring-4 focus:ring-orange-400/20' + }`} /> {!isValidAddress(address) && address !== '' && ( -

⚠ Invalid address format

+

+ + Invalid address format +

)}
@@ -62,29 +119,80 @@ export default function WhitelistForm({ onSubmit, isOwner }: WhitelistFormProps)
{status === 'success' && ( -

✓ Operation successful!

+

+ + Operation successful! +

)} {status === 'error' && ( -

⚠ Operation failed

+

+ + {errorMessage} +

+ )} + + {/* Recent Transactions */} + {previousTransactions.length > 0 && ( +
+

Recent Transactions

+
+ {previousTransactions.map((tx, index) => ( +
+
+ {tx.status ? ( + + ) : ( + + )} + + {tx.address.slice(0, 6)}...{tx.address.slice(-4)} + +
+ + {new Date(tx.timestamp).toLocaleString()} + +
+ ))} +
+
)}
diff --git a/components/PartnerBar.tsx b/components/PartnerBar.tsx index 95ef6a7..5c1048e 100644 --- a/components/PartnerBar.tsx +++ b/components/PartnerBar.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import Image from 'next/image'; const partners = [ @@ -24,25 +25,63 @@ const partners = [ ]; export default function PartnerBar() { + const containerRef = useRef(null); + + // Add subtle floating animation + useEffect(() => { + const logoElements = containerRef.current?.querySelectorAll('.partner-logo'); + if (logoElements) { + logoElements.forEach((logo, index) => { + (logo as HTMLElement).style.animationDelay = `${index * 0.2}s`; + }); + } + }, []); + return ( -
{/* Increased padding */} -
-
+
+ {/* Background gradient */} +
+ + {/* Subtle grid pattern overlay */} +
+ +
+
+

Powered by Industry Leaders

+
+
+ + diff --git a/components/SearchOnTop.tsx b/components/SearchOnTop.tsx index cc270a1..ff59d95 100644 --- a/components/SearchOnTop.tsx +++ b/components/SearchOnTop.tsx @@ -1,27 +1,95 @@ 'use client'; -import React, { useState } from 'react'; -import { useRouter, usePathname } from "next/navigation"; -import { FaSearch, FaMapPin, FaSun, FaEthereum } from 'react-icons/fa'; +import React, { useState, useRef, useEffect } from 'react'; +import { useRouter, usePathname } from "next/navigation"; +import { Search, X, Wallet, Network, ArrowRight } from 'lucide-react'; import { LoadingScreen } from "@/components/loading-screen"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import Image from "next/image"; const SearchOnTop = () => { const pathname = usePathname(); const [searchQuery, setSearchQuery] = useState(''); const [isLoading, setIsLoading] = useState(false); const [searchType, setSearchType] = useState<"onchain" | "offchain">("onchain"); + const [cryptoPrices, setCryptoPrices] = useState({ + eth: { price: '0.00', change: '0.00%' }, + bnb: { price: '0.00', change: '0.00%' } + }); + const [gasPrice, setGasPrice] = useState({ price: '0', speed: 'Standard' }); + const [expanded, setExpanded] = useState(false); const router = useRouter(); + const inputRef = useRef(null); + const searchBarRef = useRef(null); + + // Fetch real-time prices and gas data + useEffect(() => { + const fetchPrices = async () => { + try { + // Fetch crypto prices from CoinGecko + const priceResponse = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=ethereum,binancecoin&vs_currencies=usd&include_24hr_change=true' + ); + const priceData = await priceResponse.json(); + + setCryptoPrices({ + eth: { + price: priceData.ethereum.usd.toLocaleString(), + change: `${priceData.ethereum.usd_24h_change >= 0 ? '+' : ''}${priceData.ethereum.usd_24h_change.toFixed(2)}%` + }, + bnb: { + price: priceData.binancecoin.usd.toLocaleString(), + change: `${priceData.binancecoin.usd_24h_change >= 0 ? '+' : ''}${priceData.binancecoin.usd_24h_change.toFixed(2)}%` + } + }); + + // Fetch gas prices from Etherscan API + const gasResponse = await fetch('/api/etherscan?module=gastracker&action=gasoracle'); + const gasData = await gasResponse.json(); + + if (gasData.status === "1") { + setGasPrice({ + price: gasData.result.ProposeGasPrice, + speed: 'Standard' + }); + } + } catch (error) { + console.error("Error fetching market data:", error); + } + }; + + fetchPrices(); + // Refresh data every 60 seconds + const interval = setInterval(fetchPrices, 60000); + return () => clearInterval(interval); + }, []); + + // Handle click outside to collapse expanded search + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchBarRef.current && !searchBarRef.current.contains(event.target as Node)) { + setExpanded(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Skip rendering on homepage if (pathname === "/") { return null; } - + const handleSearch = async (event: React.FormEvent) => { event.preventDefault(); if (!searchQuery.trim()) return; - setIsLoading(true); try { - await new Promise((resolve) => setTimeout(resolve, 2500)); // Simulated delay + setIsLoading(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Reduced delay for better UX if (searchType === "onchain") { router.push(`/search/?address=${encodeURIComponent(searchQuery)}&network=mainnet`); } else { @@ -31,59 +99,233 @@ const SearchOnTop = () => { console.error("Search error:", error); } finally { setIsLoading(false); + setExpanded(false); + } + }; + + const clearSearch = () => { + setSearchQuery(''); + if (inputRef.current) { + inputRef.current.focus(); } }; + const handleFocus = () => { + setExpanded(true); + }; + return ( <> -
- {/* ETH Price and Gas Data */} -
- ETH Price: $1,931.60 (+1.41%) - Gas: 0.462 Gwei -
+
+
+ {/* Market Data */} +
+ {/* ETH Price */} +
+
+ + + + + + + + +
+
+ ${cryptoPrices.eth.price} + + {cryptoPrices.eth.change} + +
+
+ + {/* BNB Price */} +
+
+ + + +
+
+ ${cryptoPrices.bnb.price} + + {cryptoPrices.bnb.change} + +
+
- {/* Search Bar */} -
-
- - setSearchQuery(e.target.value)} - className="pl-10 pr-10 py-1 w-full text-gray-700 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
- -
- - - - +
+ + + setSearchQuery(e.target.value)} + onFocus={handleFocus} + className="flex-1 bg-transparent border-0 focus-visible:ring-0 text-white placeholder:text-gray-400 h-10" + /> + + {searchQuery.length > 0 && ( + + )} + +
+ +
+ + +
+ + + {/* Expanded search features */} + {expanded && ( +
+
Quick Search
+
+ {[ + { name: "Popular Tokens", route: "/" }, + { name: "Recent Transactions", route: "/transactions" }, + { name: "My Wallets", route: "/" }, + { name: "Verified Contracts", route: "/" } + ].map(item => ( + + + + + + +

Search {item.name}

+
+
+
+ ))} +
+
+ )} +
+ + {/* Right side buttons - only visible on larger screens */} +
+ + + + + + +

My Wallets

+
+
+
+ + + + + + + +

Network Status

+
+
+
+ + + ); }; diff --git a/components/home/DemoShowcase.tsx b/components/home/DemoShowcase.tsx new file mode 100644 index 0000000..2473cfb --- /dev/null +++ b/components/home/DemoShowcase.tsx @@ -0,0 +1,182 @@ + +'use client'; +import React, { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react'; + +const pageShowcases = [ + { + title: 'Crypto Price Dashboard', + description: 'Track real-time prices and market data across thousands of cryptocurrencies.', + image: '/Img/Exchange.webp', + path: '/pricetable', + color: 'from-blue-500 to-purple-600' + }, + { + title: 'Market Overview', + description: 'Get comprehensive insights into global crypto market metrics and trends.', + image: '/Img/market-overview.png', // Fixed path by removing 'public/' + path: '/market-overview', + color: 'from-green-500 to-teal-600' + }, + { + title: 'NFT Marketplace', + description: 'Buy, sell, and create unique digital assets on the PATH token ecosystem.', + image: '/Img/Web3.webp', + path: '/NFT', + color: 'from-purple-500 to-pink-600' + }, + { + title: 'NFT Collection Scanner', + description: 'Explore popular NFT collections or connect your wallet to browse your own NFTs.', + image: '/Img/Web3.webp', + path: '/NFT/collection', + color: 'from-indigo-500 to-blue-600' + }, + { + title: 'Transaction Explorer', + description: 'Search and analyze blockchain transactions with detailed visualizations.', + image: '/Img/Web3.webp', + path: '/search', + color: 'from-orange-500 to-red-600' + } +]; + +export default function DemoShowcase() { + const [activeIndex, setActiveIndex] = useState(0); + const [autoplay, setAutoplay] = useState(true); + + // Auto rotation for slides + useEffect(() => { + if (!autoplay) return; + + const interval = setInterval(() => { + setActiveIndex((prev) => (prev + 1) % pageShowcases.length); + }, 5000); + + return () => clearInterval(interval); + }, [autoplay]); + + const nextSlide = () => { + setAutoplay(false); + setActiveIndex((prev) => (prev + 1) % pageShowcases.length); + }; + + const prevSlide = () => { + setAutoplay(false); + setActiveIndex((prev) => (prev - 1 + pageShowcases.length) % pageShowcases.length); + }; + + const goToSlide = (index: number) => { + setAutoplay(false); + setActiveIndex(index); + }; + + return ( +
+
+ +

Explore Our Platform

+

+ See what CryptoPath has to offer with our comprehensive suite of blockchain tools +

+
+ +
+
+ {pageShowcases.map((showcase, index) => ( + +
+
+ {showcase.title} +
+
+ +
+
+

{showcase.title}

+ + {index + 1}/{pageShowcases.length} + +
+

{showcase.description}

+
+ + + + + View Demo + + +
+
+
+
+ ))} +
+ + + + + +
+ {pageShowcases.map((_, index) => ( +
+
+
+
+ ); +} diff --git a/components/home/FeatureCard.tsx b/components/home/FeatureCard.tsx new file mode 100644 index 0000000..e49a2c3 --- /dev/null +++ b/components/home/FeatureCard.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import Link from 'next/link'; +import { ArrowRight } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { motion } from 'framer-motion'; + +interface FeatureCardProps { + icon: React.ReactNode; + title: string; + description: string; + href: string; + imageUrl?: string; + delay: number; + language: 'en' | 'vi'; +} + +// Translation object +const translations = { + en: { + explore: 'Explore', + }, + vi: { + explore: 'Khám Phá', + }, +}; + +export default function FeatureCard({ + icon, + title, + description, + href, + imageUrl, + delay, + language +}: FeatureCardProps) { + const [isHovered, setIsHovered] = useState(false); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + +
+ {imageUrl && ( +
+
+
+ )} +
+ + +
{icon}
+

{title}

+

{description}

+ +
+ {translations[language].explore} + +
+
+ + + + ); +} \ No newline at end of file diff --git a/components/home/TrendingNFTs.tsx b/components/home/TrendingNFTs.tsx index d5b2279..dbb5847 100644 --- a/components/home/TrendingNFTs.tsx +++ b/components/home/TrendingNFTs.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { motion } from "framer-motion"; // Define the interface for the API response interface TrendingNFTData { @@ -20,6 +21,37 @@ const TrendingNFTCollections: React.FC = () => { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [selectedCard, setSelectedCard] = useState(null); + + // Animation variants + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1 + } + } + }; + + const cardVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.5, + ease: "easeOut" + } + }, + hover: { + y: -10, + transition: { + duration: 0.3, + ease: "easeInOut" + } + } + }; // Fetch NFT data on component mount useEffect(() => { @@ -32,8 +64,6 @@ const TrendingNFTCollections: React.FC = () => { return res.json(); }) .then((json: TrendingNFTData) => { - // Debug: log the JSON response - console.log("Fetched NFT Data:", json); if (isMounted) { setData(json); setIsLoading(false); @@ -53,8 +83,21 @@ const TrendingNFTCollections: React.FC = () => { // Loading state if (isLoading) { return ( -
-

Loading NFT collections...

+
+
+

Loading NFT Collections

+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+
); } @@ -62,9 +105,16 @@ const TrendingNFTCollections: React.FC = () => { // Error state if (error) { return ( -
-

Error loading NFT collections

-

{error}

+
+
+
+ + + +

Error Loading NFT Collections

+

{error}

+
+
); } @@ -74,64 +124,98 @@ const TrendingNFTCollections: React.FC = () => { if (results.length === 0) { return ( -
-

No NFT collections available

+
+
+

No NFT Collections Available

+
); } - // Render the NFT collection cards by mapping over data.nfts.results return ( -
-

Trending NFT Collections

-

- Explore the latest trending NFT collections based on sales volume and activity. -

-
- {results.map((item, index) => ( -
-
- {item.name { - (e.currentTarget as HTMLImageElement).src = "https://placekitten.com/100/100"; - }} - /> -
-

{item.name || "Unknown"}

-

- {item.chains?.[0]?.[0] || "N/A"} -

+
+ {/* Decorative background elements */} +
+
+ +
+
+
+ Hot Collections +
+

Trending NFT Collections

+

+ Explore the latest trending NFT collections based on sales volume and activity. + Stay updated with the hottest projects in the NFT space. +

+
+ + + {results.map((item, index) => ( + setSelectedCard(index === selectedCard ? null : index)} + > +
+
+
+ {/* Subtle glow effect around the image */} +
+ {item.name { + (e.currentTarget as HTMLImageElement).src = "https://placekitten.com/100/100"; + }} + /> +
+
+

{item.name || "Unknown"}

+
+
+ {item.chains?.[0]?.[0] || "N/A"} +
+
+
-
- -
- Floor price - - {item.floorPrice ? `$${item.floorPrice}` : "N/A"} - -
-
- Trading volume - - {item.volume ? `$${item.volume}` : "N/A"} - -
- -
- No. of traders - - {item.traders ? item.traders.toLocaleString() : "N/A"} - -
-
- ))} +
+
+
+ Floor price + + {item.floorPrice ? `$${item.floorPrice}` : "N/A"} + +
+
+ Trading volume + + {item.volume ? `$${item.volume}` : "N/A"} + +
+
+ No. of traders + + {item.traders ? item.traders.toLocaleString() : "N/A"} + +
+
+
+ + ))} +
); diff --git a/components/market-overview/AltcoinIndex.tsx b/components/market-overview/AltcoinIndex.tsx new file mode 100644 index 0000000..ee5adac --- /dev/null +++ b/components/market-overview/AltcoinIndex.tsx @@ -0,0 +1,130 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, AlertTriangle } from 'lucide-react'; +import axios from 'axios'; + +interface AltcoinSeasonData { + value: number; + valueText: string; + btcDominance: number; + timestamp: number; + simulated?: boolean; +} + +export default function AltcoinIndex() { + const [altcoinIndex, setAltcoinIndex] = useState(50); + const [btcDominance, setBtcDominance] = useState(60); + const [isLoading, setIsLoading] = useState(true); + const [seasonText, setSeasonText] = useState("Neutral"); + const [isSimulated, setIsSimulated] = useState(false); + + useEffect(() => { + const fetchAltcoinSeasonData = async () => { + try { + const response = await axios.get('/api/market/altcoin-season'); + setAltcoinIndex(response.data.value); + setSeasonText(response.data.valueText); + setBtcDominance(response.data.btcDominance); + setIsSimulated(response.data.simulated || false); + } catch (error) { + console.error("Failed to fetch Altcoin Season Index:", error); + // Generate a fallback value + const fallbackValue = Math.floor(Math.random() * 100) + 1; + setAltcoinIndex(fallbackValue); + setBtcDominance(60 + (Math.random() * 10 - 5)); + setIsSimulated(true); + + // Set season text based on value + if (fallbackValue <= 25) setSeasonText("Bitcoin Season"); + else if (fallbackValue < 45) setSeasonText("Bitcoin Favored"); + else if (fallbackValue < 55) setSeasonText("Neutral"); + else if (fallbackValue < 75) setSeasonText("Altcoin Favored"); + else setSeasonText("Altcoin Season"); + } finally { + setIsLoading(false); + } + }; + + fetchAltcoinSeasonData(); + }, []); + + // Determine season based on the altcoin index + const getSeason = (value: number): { text: string; color: string } => { + if (value <= 25) return { text: 'Bitcoin Season', color: 'from-orange-500 to-yellow-500' }; + if (value < 45) return { text: 'Bitcoin Favored', color: 'from-yellow-500 to-yellow-300' }; + if (value < 55) return { text: 'Neutral', color: 'from-blue-500 to-purple-500' }; + if (value < 75) return { text: 'Altcoin Favored', color: 'from-blue-500 to-blue-300' }; + return { text: 'Altcoin Season', color: 'from-blue-700 to-blue-500' }; + }; + + const season = getSeason(altcoinIndex); + + return ( + + + + + Altcoin Season Index + + + {isSimulated && ( + + + Estimated + + )} + + + + {isLoading ? ( +
+
+
+ ) : ( +
+
+
{altcoinIndex}/100
+
+ {seasonText} +
+
+ +
+
+
+ + Bitcoin Season + +
+
+ + Altcoin Season + +
+
+
+
+
+
+ 0 + 25 + 75 + 100 +
+ +
+
+ BTC Dominance: + {btcDominance.toFixed(1)}% +
+
+
+
+ )} +
+
+ ); +} diff --git a/components/market-overview/BlockchainStatsCard.tsx b/components/market-overview/BlockchainStatsCard.tsx new file mode 100644 index 0000000..49ad160 --- /dev/null +++ b/components/market-overview/BlockchainStatsCard.tsx @@ -0,0 +1,141 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, AlertTriangle, Database, Layers } from 'lucide-react'; +import axios from 'axios'; + +interface BlockchainStats { + hashRate: number; + difficulty: number; + latestHeight: number; + unconfirmedTx: number; + mempool: number; + btcMined: number; + marketPrice: number; + transactionRate: number; + minutesBetweenBlocks: number; + totalFees: number; +} + +interface ChainStatsData { + data: BlockchainStats; + timestamp: number; + simulated?: boolean; +} + +export default function BlockchainStatsCard() { + const [chainData, setChainData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSimulated, setIsSimulated] = useState(false); + + useEffect(() => { + const fetchChainData = async () => { + try { + const response = await axios.get('/api/analytics/chain-stats'); + setChainData(response.data); + setIsSimulated(response.data.simulated || false); + } catch (error) { + console.error("Failed to fetch blockchain stats:", error); + } finally { + setIsLoading(false); + } + }; + + fetchChainData(); + + // Refresh every 5 minutes + const interval = setInterval(fetchChainData, 5 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + const formatHashRate = (hashRate: number): string => { + if (hashRate >= 1000000000000000) return `${(hashRate / 1000000000000000).toFixed(2)} EH/s`; + if (hashRate >= 1000000000000) return `${(hashRate / 1000000000000).toFixed(2)} TH/s`; + if (hashRate >= 1000000000) return `${(hashRate / 1000000000).toFixed(2)} GH/s`; + if (hashRate >= 1000000) return `${(hashRate / 1000000).toFixed(2)} MH/s`; + return `${hashRate.toFixed(2)} H/s`; + }; + + const formatDifficulty = (difficulty: number): string => { + if (difficulty >= 1000000000000) return `${(difficulty / 1000000000000).toFixed(2)} T`; + if (difficulty >= 1000000000) return `${(difficulty / 1000000000).toFixed(2)} G`; + if (difficulty >= 1000000) return `${(difficulty / 1000000).toFixed(2)} M`; + return `${difficulty.toFixed(2)}`; + }; + + // Function to safely access data + const safeValue = (value: any, defaultVal: string = '0') => { + return value !== undefined && value !== null ? value : defaultVal; + }; + + return ( + + + + + + Bitcoin Network Stats + + + {isSimulated && ( + + + Estimated + + )} + + + + {isLoading ? ( +
+
+
+ ) : chainData && chainData.data ? ( +
+
+
Hash Rate
+
+ {formatHashRate(chainData.data.hashRate || 0)} +
+
+
+
Difficulty
+
+ {formatDifficulty(chainData.data.difficulty || 0)} +
+
+
+
Block Height
+
+ {(chainData.data.latestHeight || 0).toLocaleString()} +
+
+
+
Mempool Size
+
+ {(chainData.data.mempool || 0).toLocaleString()} tx +
+
+
+
Avg Block Time
+
+ {typeof chainData.data.minutesBetweenBlocks === 'number' ? + chainData.data.minutesBetweenBlocks.toFixed(2) : '0.00'} min +
+
+
+
Tx Rate
+
+ {typeof chainData.data.transactionRate === 'number' ? + chainData.data.transactionRate.toFixed(2) : '0.00'}/sec +
+
+
+ ) : ( +
+ Unable to fetch blockchain data +
+ )} +
+
+ ); +} diff --git a/components/market-overview/CryptoCard.tsx b/components/market-overview/CryptoCard.tsx new file mode 100644 index 0000000..234e4c2 --- /dev/null +++ b/components/market-overview/CryptoCard.tsx @@ -0,0 +1,78 @@ + +import React from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { ArrowUpRight, ArrowDownRight } from 'lucide-react'; +import Image from 'next/image'; + +interface CryptoCardProps { + name: string; + symbol: string; + price: number; + change24h: number; + icon: string; + fallbackIcon: string | null; +} + +export default function CryptoCard({ + name, + symbol, + price, + change24h, + icon, + fallbackIcon +}: CryptoCardProps) { + const isPositive = change24h >= 0; + + // Format price based on value + const formatPrice = (price: number): string => { + if (price >= 1000) { + return `$${price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + } else if (price >= 1) { + return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } else { + return `$${price.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 })}`; + } + }; + + return ( + + +
+
+ {/* Try to load the specific icon, fall back to placeholder */} +
+ {symbol} { + // If specific icon fails, try fallback or use placeholder + if (fallbackIcon) { + (e.target as HTMLImageElement).src = fallbackIcon; + } else { + (e.target as HTMLImageElement).src = '/icons/token-placeholder.png'; + } + }} + /> +
+
+ {symbol} +
+ +
+ {formatPrice(price)} +
+ +
+ {isPositive ? ( + + ) : ( + + )} + {isPositive ? '+' : ''}{change24h.toFixed(2)}% +
+
+
+ ); +} diff --git a/components/market-overview/DefiTvlCard.tsx b/components/market-overview/DefiTvlCard.tsx new file mode 100644 index 0000000..6d371c1 --- /dev/null +++ b/components/market-overview/DefiTvlCard.tsx @@ -0,0 +1,146 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, AlertTriangle, LineChart, ExternalLink } from 'lucide-react'; +import axios from 'axios'; +import { + LineChart as RechartsLineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer +} from 'recharts'; +import Link from 'next/link'; + +interface TvlDataPoint { + date: string; + tvl: number; +} + +interface TvlData { + data: TvlDataPoint[]; + totalTvl: number; + timestamp: number; + simulated?: boolean; +} + +export default function DefiTvlCard() { + const [tvlData, setTvlData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSimulated, setIsSimulated] = useState(false); + + useEffect(() => { + const fetchTvlData = async () => { + try { + const response = await axios.get('/api/analytics/defi-tvl'); + setTvlData(response.data); + setIsSimulated(response.data.simulated || false); + } catch (error) { + console.error("Failed to fetch TVL data:", error); + } finally { + setIsLoading(false); + } + }; + + fetchTvlData(); + + // Refresh every 12 hours + const interval = setInterval(fetchTvlData, 12 * 60 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + const formatTvl = (value: number): string => { + if (value >= 1000000000000) return `$${(value / 1000000000000).toFixed(2)}T`; + if (value >= 1000000000) return `$${(value / 1000000000).toFixed(2)}B`; + if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`; + if (value >= 1000) return `$${(value / 1000).toFixed(2)}K`; + return `$${value.toFixed(2)}`; + }; + + return ( + + + + + + DeFi Total Value Locked + + + {isSimulated && ( + + + Estimated + + )} + + + + {isLoading ? ( +
+
+
+ ) : tvlData && tvlData.data ? ( + <> +
+ + + + + [formatTvl(value), "TVL"]} + labelFormatter={(label) => `Date: ${label}`} + contentStyle={{ + backgroundColor: '#1F2937', + borderColor: '#374151', + borderRadius: '4px', + color: 'white' + }} + /> + + + +
+
+
+ {formatTvl(tvlData.totalTvl)} +
+

+ Total Value Locked across all DeFi protocols +

+ + View on DefiLlama + + +
+ + ) : ( +
+ No DeFi TVL data available +
+ )} +
+
+ ); +} diff --git a/components/market-overview/DominanceCard.tsx b/components/market-overview/DominanceCard.tsx new file mode 100644 index 0000000..79c9f0a --- /dev/null +++ b/components/market-overview/DominanceCard.tsx @@ -0,0 +1,71 @@ + +import React from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info } from 'lucide-react'; + +interface DominanceCardProps { + btcDominance: number; + ethDominance: number; + othersDominance: number; +} + +export default function DominanceCard({ + btcDominance, + ethDominance, + othersDominance +}: DominanceCardProps) { + return ( + + + + Bitcoin Dominance + + + + +
+
+
+
+
+ Bitcoin +
+ {btcDominance.toFixed(1)}% +
+
+
+
+ Ethereum +
+ {ethDominance.toFixed(1)}% +
+
+
+
+ Others +
+ {othersDominance.toFixed(1)}% +
+
+ +
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/components/market-overview/ExchangeVolumeCard.tsx b/components/market-overview/ExchangeVolumeCard.tsx new file mode 100644 index 0000000..d55a30b --- /dev/null +++ b/components/market-overview/ExchangeVolumeCard.tsx @@ -0,0 +1,156 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, AlertTriangle } from 'lucide-react'; +import axios from 'axios'; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip as RechartsTooltip, + ResponsiveContainer, + Cell +} from 'recharts'; + +interface ExchangeVolume { + name: string; + volume: number; + color: string; +} + +interface ExchangeVolumeData { + data: ExchangeVolume[]; + totalVolume: number; + timestamp: number; + simulated?: boolean; +} + +export default function ExchangeVolumeCard() { + const [volumeData, setVolumeData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSimulated, setIsSimulated] = useState(false); + + useEffect(() => { + const fetchExchangeVolumes = async () => { + try { + const response = await axios.get('/api/analytics/exchange-volumes'); + setVolumeData(response.data); + setIsSimulated(response.data.simulated || false); + } catch (error) { + console.error("Failed to fetch exchange volume data:", error); + + // Fallback data + const fallbackData: ExchangeVolumeData = { + data: [ + { name: 'Binance', volume: 25000000000, color: '#F0B90B' }, + { name: 'Coinbase', volume: 12000000000, color: '#1652F0' }, + { name: 'OKX', volume: 8000000000, color: '#1A1B1F' }, + { name: 'Huobi', volume: 5000000000, color: '#1F94E0' }, + { name: 'KuCoin', volume: 3000000000, color: '#26A17B' }, + ], + totalVolume: 53000000000, + timestamp: Date.now(), + simulated: true + }; + + setVolumeData(fallbackData); + setIsSimulated(true); + } finally { + setIsLoading(false); + } + }; + + fetchExchangeVolumes(); + + // Refresh every 1 hour + const interval = setInterval(fetchExchangeVolumes, 60 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + const formatVolume = (volume: number): string => { + if (volume >= 1000000000) { + return `$${(volume / 1000000000).toFixed(2)}B`; + } else if (volume >= 1000000) { + return `$${(volume / 1000000).toFixed(2)}M`; + } else { + return `$${volume.toLocaleString()}`; + } + }; + + return ( + + + + + Exchange Volume (24h) + + + {isSimulated && ( + + + Estimated + + )} + + + + {isLoading ? ( +
+
+
+ ) : volumeData ? ( + <> +
+ + + + + [formatVolume(value), "Volume"]} + contentStyle={{ + backgroundColor: '#1F2937', + borderColor: '#374151', + borderRadius: '4px', + color: 'white' + }} + /> + + {volumeData.data.map((entry, index) => ( + + ))} + + + +
+
+
+ {formatVolume(volumeData.totalVolume)} +
+

Total 24h Trading Volume

+
+ + ) : ( +
+ Unable to fetch exchange volume data +
+ )} +
+
+ ); +} diff --git a/components/market-overview/FearGreedIndex.tsx b/components/market-overview/FearGreedIndex.tsx new file mode 100644 index 0000000..c4dc209 --- /dev/null +++ b/components/market-overview/FearGreedIndex.tsx @@ -0,0 +1,121 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, AlertTriangle, ExternalLink } from 'lucide-react'; +import Link from 'next/link'; +import axios from 'axios'; + +interface FearGreedData { + value: number; + valueText: string; + timestamp: number; + simulated?: boolean; +} + +export default function FearGreedIndex() { + const [fgIndex, setFgIndex] = useState(50); + const [fgText, setFgText] = useState("Neutral"); + const [isLoading, setIsLoading] = useState(true); + const [isSimulated, setIsSimulated] = useState(false); + + useEffect(() => { + const fetchFearGreedIndex = async () => { + try { + // Try to fetch from real API + const response = await fetch('/api/analytics/fear-greed-index'); + + if (response.ok) { + const data = await response.json(); + setFgIndex(data.value); + setFgText(data.valueText); + setIsSimulated(data.simulated || false); + } else { + throw new Error('API request failed'); + } + } catch (error) { + console.error("Failed to fetch Fear & Greed Index:", error); + + // Generate fallback data + const fallbackValue = Math.floor(Math.random() * 20) + 15; // Random value between 15-35 + setFgIndex(fallbackValue); + setIsSimulated(true); + + // Set text based on value + if (fallbackValue <= 20) setFgText("Extreme Fear"); + else if (fallbackValue <= 40) setFgText("Fear"); + else if (fallbackValue <= 60) setFgText("Neutral"); + else if (fallbackValue <= 80) setFgText("Greed"); + else setFgText("Extreme Greed"); + } finally { + setIsLoading(false); + } + }; + + fetchFearGreedIndex(); + }, []); + + // Determine color based on the fear & greed index + const getSentiment = (value: number): { text: string; color: string } => { + if (value <= 20) return { text: "Extreme Fear", color: 'from-red-600 to-red-500' }; + if (value <= 40) return { text: "Fear", color: 'from-orange-600 to-orange-500' }; + if (value <= 60) return { text: "Neutral", color: 'from-yellow-500 to-yellow-400' }; + if (value <= 80) return { text: "Greed", color: 'from-green-500 to-green-400' }; + return { text: "Extreme Greed", color: 'from-green-700 to-green-600' }; + }; + + const sentiment = getSentiment(fgIndex); + const rotation = (fgIndex / 100) * 180 - 90; // Convert to -90 to 90 deg range + + return ( + + + + + Fear and Greed Index + + + {isSimulated && ( + + + Estimated + + )} + + + + {isLoading ? ( +
+
+
+ ) : ( + <> +
+ {/* Gauge background */} +
+ + {/* Gauge foreground */} +
+
+ {/* Needle */} +
+ + {/* Needle base */} +
+
+
+
+ +
+
{fgIndex}
+
+ {fgText} +
+
+ + )} +
+
+ ); +} diff --git a/components/market-overview/GasPriceCard.tsx b/components/market-overview/GasPriceCard.tsx new file mode 100644 index 0000000..967e0e5 --- /dev/null +++ b/components/market-overview/GasPriceCard.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, AlertTriangle, Fuel } from 'lucide-react'; +import axios from 'axios'; + +interface GasPriceData { + slow: number; + average: number; + fast: number; + baseFee: number; + timestamp: number; + simulated?: boolean; +} + +export default function GasPriceCard() { + const [gasData, setGasData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSimulated, setIsSimulated] = useState(false); + + useEffect(() => { + const fetchGasData = async () => { + try { + const response = await axios.get('/api/analytics/gas-prices'); + setGasData(response.data); + setIsSimulated(response.data.simulated || false); + } catch (error) { + console.error("Failed to fetch gas prices:", error); + } finally { + setIsLoading(false); + } + }; + + fetchGasData(); + + // Refresh every 30 seconds + const interval = setInterval(fetchGasData, 30000); + return () => clearInterval(interval); + }, []); + + const getGasColor = (price: number): string => { + if (price < 20) return 'text-green-500'; + if (price < 50) return 'text-yellow-500'; + if (price < 100) return 'text-orange-500'; + return 'text-red-500'; + }; + + return ( + + + + + + Ethereum Gas Prices + + + {isSimulated && ( + + + Estimated + + )} + + + + {isLoading ? ( +
+
+
+ ) : gasData ? ( +
+
+
+
Slow
+
+ {gasData.slow} Gwei +
+
+
+
Average
+
+ {gasData.average} Gwei +
+
+
+
Fast
+
+ {gasData.fast} Gwei +
+
+
+ +
+
+ Base Fee: + {gasData.baseFee} Gwei +
+
+
+ ) : ( +
+ Unable to fetch gas prices +
+ )} +
+
+ ); +} diff --git a/components/market-overview/MarketCapChart.tsx b/components/market-overview/MarketCapChart.tsx new file mode 100644 index 0000000..128b7be --- /dev/null +++ b/components/market-overview/MarketCapChart.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; + +interface MarketCapChartProps { + data: any; + timeframe: string; +} + +export default function MarketCapChart({ data, timeframe }: MarketCapChartProps) { + // Return empty chart if no data + if (!data || !data.prices || data.prices.length === 0) { + return ( +
+

No chart data available

+
+ ); + } + + // Process chart data based on timeframe + const processChartData = () => { + const dataPoints: { date: string; price: number; volume: number }[] = []; + let interval = 1; // Default interval + + // Adjust interval based on timeframe and data length to avoid overcrowding + if (timeframe === '1y' && data.prices.length > 30) { + interval = Math.floor(data.prices.length / 30); + } else if (timeframe === '30d' && data.prices.length > 30) { + interval = Math.floor(data.prices.length / 30); + } + + // Format date based on timeframe + const formatDate = (timestamp: number) => { + const date = new Date(timestamp); + if (timeframe === '24h') { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else if (timeframe === '7d') { + return date.toLocaleDateString([], { weekday: 'short' }); + } else { + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + }; + + // Select points based on interval + for (let i = 0; i < data.prices.length; i += interval) { + const [timestamp, price] = data.prices[i]; + const volume = data.total_volumes[i] ? data.total_volumes[i][1] : 0; + + dataPoints.push({ + date: formatDate(timestamp), + price, + volume, + }); + } + + return dataPoints; + }; + + const chartData = processChartData(); + const isPositive = chartData[0].price <= chartData[chartData.length - 1].price; + const chartColor = isPositive ? '#10B981' : '#EF4444'; + + // Format Y-axis values for Bitcoin prices + const formatYAxis = (value: number) => { + if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`; + return `$${value.toFixed(0)}`; + }; + + return ( +
+ + + + + + + + + + + + { + if (active && payload && payload.length) { + return ( +
+

{payload[0].payload.date}

+

Price: ${payload[0].value?.toLocaleString()}

+
+ ); + } + return null; + }} + /> + +
+
+
+ ); +} diff --git a/components/market-overview/MarketIndexCard.tsx b/components/market-overview/MarketIndexCard.tsx new file mode 100644 index 0000000..98f3ba5 --- /dev/null +++ b/components/market-overview/MarketIndexCard.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, ArrowUpRight, ArrowDownRight } from 'lucide-react'; + +interface MarketIndex { + name: string; + symbol: string; + price: number; + change: number; + changePercent: number; +} + +export default function MarketIndexCard() { + const [indices, setIndices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchMarketIndices = async () => { + try { + // In a real app, this would be an API call + // Using simulated data for demonstration + const simulatedIndices: MarketIndex[] = [ + { + name: 'S&P 500', + symbol: 'SPX', + price: 4500 + (Math.random() * 200 - 100), + change: Math.random() * 40 - 20, + changePercent: (Math.random() * 2 - 1) + }, + { + name: 'Nasdaq', + symbol: 'NDX', + price: 15000 + (Math.random() * 500 - 250), + change: Math.random() * 100 - 50, + changePercent: (Math.random() * 2 - 1) + }, + { + name: 'Dow Jones', + symbol: 'DJI', + price: 35000 + (Math.random() * 1000 - 500), + change: Math.random() * 200 - 100, + changePercent: (Math.random() * 2 - 1) + }, + { + name: 'Gold', + symbol: 'XAU', + price: 2000 + (Math.random() * 100 - 50), + change: Math.random() * 30 - 15, + changePercent: (Math.random() * 2 - 1) + } + ]; + + setIndices(simulatedIndices); + } catch (error) { + console.error("Failed to fetch market indices:", error); + } finally { + setIsLoading(false); + } + }; + + fetchMarketIndices(); + + const interval = setInterval(fetchMarketIndices, 60 * 1000); + return () => clearInterval(interval); + }, []); + + return ( + + + + Traditional Markets + + + + + {isLoading ? ( +
+
+
+ ) : ( +
+ {indices.map((index) => ( +
+
+
{index.name}
+
{index.symbol}
+
+
+
{index.price.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+
= 0 ? 'text-green-500' : 'text-red-500'}`}> + {index.changePercent >= 0 ? ( + + ) : ( + + )} + {index.changePercent >= 0 ? '+' : ''} + {index.changePercent.toFixed(2)}% +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/components/market-overview/MarketSentimentCard.tsx b/components/market-overview/MarketSentimentCard.tsx new file mode 100644 index 0000000..5ff0e39 --- /dev/null +++ b/components/market-overview/MarketSentimentCard.tsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect, JSX } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, AlertTriangle, TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import axios from 'axios'; + +interface SentimentData { + score: number; + socialMediaScore: number; + newsScore: number; + redditMentions: number; + twitterMentions: number; + timestamp: number; + simulated?: boolean; +} + +export default function MarketSentimentCard() { + const [sentimentData, setSentimentData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSimulated, setIsSimulated] = useState(false); + + useEffect(() => { + const fetchSentimentData = async () => { + try { + const response = await axios.get('/api/analytics/market-sentiment'); + setSentimentData(response.data); + setIsSimulated(response.data.simulated || false); + } catch (error) { + console.error("Failed to fetch sentiment data:", error); + } finally { + setIsLoading(false); + } + }; + + fetchSentimentData(); + + // Refresh every 30 minutes + const interval = setInterval(fetchSentimentData, 30 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + const getSentimentText = (score: number): { text: string; color: string; icon: JSX.Element } => { + if (score >= 70) return { + text: 'Extremely Bullish', + color: 'text-green-500', + icon: + }; + if (score >= 60) return { + text: 'Bullish', + color: 'text-green-400', + icon: + }; + if (score >= 45) return { + text: 'Slightly Bullish', + color: 'text-green-300', + icon: + }; + if (score > 55) return { + text: 'Neutral', + color: 'text-gray-400', + icon: + }; + if (score > 40) return { + text: 'Slightly Bearish', + color: 'text-red-300', + icon: + }; + if (score > 30) return { + text: 'Bearish', + color: 'text-red-400', + icon: + }; + return { + text: 'Extremely Bearish', + color: 'text-red-500', + icon: + }; + }; + + return ( + + + + + Market Sentiment Analysis + + + {isSimulated && ( + + + Estimated + + )} + + + + {isLoading ? ( +
+
+
+ ) : sentimentData ? ( +
+
+
{sentimentData.score.toFixed(0)}/100
+ + {(() => { + const sentiment = getSentimentText(sentimentData.score); + return ( +
+ {sentiment.icon} + {sentiment.text} +
+ ); + })()} +
+ +
+
+
= 55 ? 'bg-green-500' : + sentimentData.score >= 45 ? 'bg-yellow-500' : 'bg-red-500' + }`} + style={{ width: `${sentimentData.score}%` }} + >
+
+
+ +
+
+
Social Media
+
= 55 ? 'text-green-500' : + sentimentData.socialMediaScore >= 45 ? 'text-yellow-500' : 'text-red-500' + }`}> + {sentimentData.socialMediaScore.toFixed(0)}/100 +
+
+
+
News
+
= 55 ? 'text-green-500' : + sentimentData.newsScore >= 45 ? 'text-yellow-500' : 'text-red-500' + }`}> + {sentimentData.newsScore.toFixed(0)}/100 +
+
+
+ +
+
Reddit: {sentimentData.redditMentions.toLocaleString()} mentions
+
Twitter: {sentimentData.twitterMentions.toLocaleString()} mentions
+
+
+ ) : ( +
+ Unable to fetch sentiment data +
+ )} +
+
+ ); +} diff --git a/components/market-overview/NftStatsCard.tsx b/components/market-overview/NftStatsCard.tsx new file mode 100644 index 0000000..cf578c3 --- /dev/null +++ b/components/market-overview/NftStatsCard.tsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, AlertTriangle, Image as ImageIcon, ExternalLink } from 'lucide-react'; +import axios from 'axios'; +import Link from 'next/link'; + +interface NftCollection { + name: string; + symbol: string; + floorPrice: number; + volume24h: number; + totalVolume: number; + owners: number; +} + +interface NftStatsData { + collections: NftCollection[]; + timestamp: number; + simulated?: boolean; +} + +export default function NftStatsCard() { + const [nftData, setNftData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSimulated, setIsSimulated] = useState(false); + + useEffect(() => { + const fetchNftData = async () => { + try { + const response = await axios.get('/api/analytics/nft-stats'); + setNftData(response.data); + setIsSimulated(response.data.simulated || false); + } catch (error) { + console.error("Failed to fetch NFT stats:", error); + } finally { + setIsLoading(false); + } + }; + + fetchNftData(); + + // Refresh every 6 hours + const interval = setInterval(fetchNftData, 6 * 60 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + return ( + + + + + + Top NFT Collections + + + {isSimulated && ( + + + Estimated + + )} + + + + {isLoading ? ( +
+
+
+ ) : nftData && nftData.collections && nftData.collections.length > 0 ? ( +
+
+ + + + + + + + + + {nftData.collections.map((collection, index) => ( + + + + + + ))} + +
CollectionFloor Price24h Vol
+
{collection.name}
+
{collection.symbol}
+
+
{collection.floorPrice.toFixed(2)} ETH
+
+
{collection.volume24h.toFixed(1)} ETH
+
+
+ +
+ + View NFT Explorer + + +
+
+ ) : ( +
+ No NFT stats available +
+ )} +
+
+ ); +} diff --git a/components/market-overview/StakingYieldsCard.tsx b/components/market-overview/StakingYieldsCard.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/market-overview/TrendingCoinsCard.tsx b/components/market-overview/TrendingCoinsCard.tsx new file mode 100644 index 0000000..5460e90 --- /dev/null +++ b/components/market-overview/TrendingCoinsCard.tsx @@ -0,0 +1,131 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Info, AlertTriangle, Flame, ExternalLink } from 'lucide-react'; +import axios from 'axios'; +import Image from 'next/image'; +import Link from 'next/link'; + +interface TrendingCoin { + id: string; + name: string; + symbol: string; + thumb: string; + price_btc: number; + market_cap_rank: number; + score: number; +} + +interface TrendingData { + coins: TrendingCoin[]; + timestamp: number; + simulated?: boolean; +} + +export default function TrendingCoinsCard() { + const [trendingData, setTrendingData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSimulated, setIsSimulated] = useState(false); + + useEffect(() => { + const fetchTrendingData = async () => { + try { + const response = await axios.get('/api/analytics/trending-coins'); + setTrendingData(response.data); + setIsSimulated(response.data.simulated || false); + } catch (error) { + console.error("Failed to fetch trending coins:", error); + } finally { + setIsLoading(false); + } + }; + + fetchTrendingData(); + + // Refresh every hour + const interval = setInterval(fetchTrendingData, 60 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + const formatBtcPrice = (price: number): string => { + if (price < 0.00001) return price.toExponential(2); + return price.toFixed(8); + }; + + return ( + + + + + + Trending Coins + + + {isSimulated && ( + + + Estimated + + )} + + + + {isLoading ? ( +
+
+
+ ) : trendingData && trendingData.coins.length > 0 ? ( +
+ {trendingData.coins.map((coin, index) => ( +
+
+ {index + 1}. +
+ {coin.name} { + // If image fails, show first letter of coin name + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + target.parentElement!.innerHTML = coin.symbol.charAt(0).toUpperCase(); + target.parentElement!.style.display = 'flex'; + target.parentElement!.style.justifyContent = 'center'; + target.parentElement!.style.alignItems = 'center'; + }} + /> +
+
+
{coin.name}
+
{coin.symbol}
+
+
+
+
Price in BTC
+
₿ {formatBtcPrice(coin.price_btc)}
+
+
+ ))} + +
+ + View More on CoinGecko + + +
+
+ ) : ( +
+ No trending coins data available +
+ )} +
+
+ ); +} diff --git a/components/market-overview/WhaleAlertsCard.tsx b/components/market-overview/WhaleAlertsCard.tsx new file mode 100644 index 0000000..72cb375 --- /dev/null +++ b/components/market-overview/WhaleAlertsCard.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { ArrowRight, Wallet } from 'lucide-react'; + +interface WhaleTransaction { + id: string; + symbol: string; + amount: number; + value: number; + from: string; + to: string; + type: 'withdrawal' | 'deposit' | 'transfer'; + timestamp: number; +} + +interface WhaleData { + transactions: WhaleTransaction[]; + totalValue: number; + timestamp: number; + simulated: boolean; +} + +export default function WhaleAlertsCard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + try { + const response = await fetch('/api/analytics/whale-alerts?limit=5'); + if (!response.ok) throw new Error('Failed to fetch whale alerts'); + const whaleData = await response.json(); + setData(whaleData); + } catch (error) { + console.error('Error fetching whale alerts:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); + // Refresh every 2 minutes + const interval = setInterval(fetchData, 2 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + const formatAddress = (address: string) => { + if (address.includes('.') || address.includes(' ')) return address; // Exchange name + return `${address.slice(0, 6)}...${address.slice(-4)}`; + }; + + const formatValue = (value: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 1 + }).format(value); + }; + + const formatAmount = (amount: number, symbol: string) => { + const precision = symbol === 'BTC' ? 2 : 1; + return `${amount.toFixed(precision)} ${symbol}`; + }; + + const getTransactionColor = (type: string) => { + switch (type) { + case 'withdrawal': return 'text-red-400'; + case 'deposit': return 'text-green-400'; + default: return 'text-blue-400'; + } + }; + + if (loading) { + return ( + + + + + Whale Alerts + + + + {[...Array(5)].map((_, i) => ( + + ))} + + + ); + } + + return ( + + +
+ + + Whale Alerts + + {data?.simulated && ( + + Simulated Data + + )} +
+
+ + {data?.transactions.map((tx) => ( +
+
+
+ {formatAddress(tx.from)} + + {formatAddress(tx.to)} +
+
+ + {formatAmount(tx.amount, tx.symbol)} + + + ({formatValue(tx.value)}) + +
+
+ + {tx.type} + +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/components/portfolio/ActivityTable.tsx b/components/portfolio/ActivityTable.tsx new file mode 100644 index 0000000..690fe9f --- /dev/null +++ b/components/portfolio/ActivityTable.tsx @@ -0,0 +1,150 @@ +"use client"; +import React, { useState } from "react"; +import { ArrowDownUp, ArrowDown, ArrowUp, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ethers } from "ethers"; + +interface Transaction { + hash: string; + timeStamp: string; + from: string; + to: string; + value: string; + isError: string; +} + +interface ActivityTableProps { + transactions: Transaction[]; + walletAddress: string; + isLoading: boolean; +} + +const ActivityTable: React.FC = ({ transactions, walletAddress, isLoading }) => { + const [currentPage, setCurrentPage] = useState(1); + const txPerPage = 5; + + const indexOfLastTx = currentPage * txPerPage; + const indexOfFirstTx = indexOfLastTx - txPerPage; + const currentTxs = transactions.slice(indexOfFirstTx, indexOfLastTx); + const totalPages = Math.ceil(transactions.length / txPerPage); + + const nextPage = () => currentPage < totalPages && setCurrentPage(currentPage + 1); + const prevPage = () => currentPage > 1 && setCurrentPage(currentPage - 1); + + const formatAddress = (address: string) => + address ? `${address.slice(0, 6)}...${address.slice(-4)}` : "Unknown"; + + const formatEthValue = (value: string) => + ethers.utils.formatEther(value || "0").substring(0, 6); + + const getTransactionType = (tx: Transaction) => + tx.to.toLowerCase() === walletAddress.toLowerCase() ? "incoming" : "outgoing"; + + return ( +
+
+
+
+ +
+

Activity

+
+ {transactions.length > 0 && ( +
+ {indexOfFirstTx + 1}-{Math.min(indexOfLastTx, transactions.length)} of {transactions.length} +
+ )} +
+ +
+ {isLoading ? ( + [...Array(5)].map((_, i) => ) + ) : transactions.length > 0 ? ( + currentTxs.map((tx, index) => ( +
+
+
+
+ {tx.isError === "1" ? ( + "✕" + ) : getTransactionType(tx) === "incoming" ? ( + + ) : ( + + )} +
+
+ {tx.isError === "1" + ? "Failed" + : getTransactionType(tx) === "incoming" + ? "Received" + : "Sent"} +
+
+
+ {formatDistanceToNow(parseInt(tx.timeStamp) * 1000, { addSuffix: true })} +
+
+
+

From: {formatAddress(tx.from)}

+

To: {formatAddress(tx.to)}

+

Value: {formatEthValue(tx.value)} ETH

+ + View on Etherscan + +
+
+ )) + ) : ( +
+ +

No activity found

+
+ )} +
+ + {transactions.length > txPerPage && ( +
+ + +
+ )} +
+ ); +}; + +export default ActivityTable; \ No newline at end of file diff --git a/components/portfolio/Allocation.tsx b/components/portfolio/Allocation.tsx new file mode 100644 index 0000000..d1cbdd7 --- /dev/null +++ b/components/portfolio/Allocation.tsx @@ -0,0 +1,143 @@ +"use client"; +import React from "react"; +import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts"; +import { CircleDollarSign, PieChart as PieChartIcon } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface Token { + name: string; + symbol: string; + balance: string; + tokenAddress: string; + decimals: number; + value: number; +} + +interface AllocationChartProps { + tokens: Token[]; + ethBalance: string; + isLoading: boolean; +} + +const AllocationChart: React.FC = ({ tokens, ethBalance, isLoading }) => { + const calculateAllocation = () => { + const ethValue = parseFloat(ethBalance || "0"); + + if (tokens.length === 0 && ethValue <= 0) { + return []; + } + + // Danh sách màu cố định, đảm bảo mỗi coin có màu riêng biệt + const colors = [ + "#f6b355", // ETH + "#FF6B6B", // Token 1 + "#48dbfb", // Token 2 + "#1dd1a1", // Token 3 + "#feca57", // Token 4 + "#ff9ff3", // Token 5 + "#54a0ff", // Token 6 + ]; + + const assets = ethValue > 0 ? [{ name: "ETH", value: ethValue, color: colors[0] }] : []; + const uniqueTokens = new Map(); + + tokens.forEach((token, index) => { + if (uniqueTokens.has(token.symbol) || token.value <= 0) return; + + uniqueTokens.set(token.symbol, true); + const colorIndex = assets.length; // Đảm bảo màu không trùng với ETH hoặc token khác + assets.push({ + name: token.symbol, + value: token.value, + color: colors[colorIndex % colors.length], // Lấy màu từ danh sách + }); + }); + + const topAssets = assets.sort((a, b) => b.value - a.value).slice(0, 7); + const totalValue = topAssets.reduce((sum, asset) => sum + asset.value, 0); + return totalValue > 0 ? topAssets : []; + }; + + const data = calculateAllocation(); + + const formatTooltipValue = (value: number) => `${value.toFixed(4)} ETH`; + + const renderLegend = (props: any) => { + const { payload } = props; + return ( +
    + {payload.map((entry: any, index: number) => ( +
  • + + {entry.value} +
  • + ))} +
+ ); + }; + + return ( +
+
+
+ +
+

Asset Allocation

+
+ +
+ {isLoading ? ( +
+ +
+ ) : data.length > 0 ? ( + + + + {data.map((entry, index) => ( + + ))} + + + + + + ) : ( +
+ +

No assets found to display allocation

+
+ )} +
+
+ ); +}; + +export default AllocationChart; \ No newline at end of file diff --git a/components/portfolio/BalanceCard.tsx b/components/portfolio/BalanceCard.tsx new file mode 100644 index 0000000..c56d5e3 --- /dev/null +++ b/components/portfolio/BalanceCard.tsx @@ -0,0 +1,90 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { ArrowUpRight, Wallet } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface BalanceCardProps { + balance: string; + isLoading: boolean; +} + +const BalanceCard: React.FC = ({ balance, isLoading }) => { + const [currentTime, setCurrentTime] = useState(""); + + useEffect(() => { + setCurrentTime(new Date().toLocaleTimeString()); + }, [balance]); + + const formattedBalance = isLoading || !balance ? "0.0000" : parseFloat(balance).toFixed(4); + + return ( +
+ {/* Hiệu ứng hover border */} +
{ + e.currentTarget.style.borderColor = "#f6b355"; + e.currentTarget.style.opacity = "0.8"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = "transparent"; + e.currentTarget.style.opacity = "0"; + }} + /> + +
+
+
+ +
+

+ ETH Balance +

+
+ + Etherscan + + +
+ +
+ {isLoading ? ( + + ) : ( +
+ + {formattedBalance} + + ETH +
+ )} +
+ {isLoading ? ( + + ) : ( + Last updated: {currentTime || "Just now"} + )} +
+
+
+ ); +}; + +export default BalanceCard; \ No newline at end of file diff --git a/components/portfolio/HistoryCard.tsx b/components/portfolio/HistoryCard.tsx new file mode 100644 index 0000000..f50feb4 --- /dev/null +++ b/components/portfolio/HistoryCard.tsx @@ -0,0 +1,104 @@ +"use client"; +import React, { useState } from "react"; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; +import { AreaChart, Clock } from "lucide-react"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ethers } from "ethers"; + +interface Transaction { + hash: string; + timeStamp: string; + value: string; // Chuỗi thập phân từ API (wei) +} + +interface HistoryChartProps { + transactions: Transaction[]; + isLoading: boolean; +} + +const HistoryChart: React.FC = ({ transactions, isLoading }) => { + const [selectedPeriod, setSelectedPeriod] = useState("all"); + + const processChartData = (txs: Transaction[], period: string) => { + if (!txs || txs.length === 0) return []; + + const sortedTxs = [...txs].sort((a, b) => parseInt(a.timeStamp) - parseInt(b.timeStamp)); + let filteredTxs = sortedTxs; + const now = Math.floor(Date.now() / 1000); + + if (period === "1d") { + filteredTxs = sortedTxs.filter((tx) => now - parseInt(tx.timeStamp) < 86400); + } else if (period === "7d") { + filteredTxs = sortedTxs.filter((tx) => now - parseInt(tx.timeStamp) < 604800); + } else if (period === "30d") { + filteredTxs = sortedTxs.filter((tx) => now - parseInt(tx.timeStamp) < 2592000); + } + + const groupedData: Record = {}; + + filteredTxs.forEach((tx) => { + const timestampMs = parseInt(tx.timeStamp) * 1000; + if (isNaN(timestampMs)) return; + + const date = new Date(timestampMs).toLocaleDateString(); + // Chuyển đổi giá trị từ wei sang ETH + const valueInWei = tx.value || "0"; // Đảm bảo không có undefined + const valueInEth = parseFloat(ethers.utils.formatEther(valueInWei)); + + if (groupedData[date]) { + groupedData[date].value += valueInEth; + } else { + groupedData[date] = { date, value: valueInEth }; + } + }); + + return Object.values(groupedData); + }; + + const chartData = processChartData(transactions, selectedPeriod); + + return ( +
+
+
+
+ +
+

Transaction History

+
+ + + 1D + 7D + 30D + All + + +
+ +
+ {isLoading ? ( + + ) : chartData.length > 0 ? ( + + + + + + `${value.toFixed(4)} ETH`} /> + + + + ) : ( +
+ +

No transaction history available

+
+ )} +
+
+ ); +}; + +export default HistoryChart; \ No newline at end of file diff --git a/components/portfolio/NFTsCard.tsx b/components/portfolio/NFTsCard.tsx new file mode 100644 index 0000000..76f8dc8 --- /dev/null +++ b/components/portfolio/NFTsCard.tsx @@ -0,0 +1,147 @@ +"use client"; +import React, { useState } from "react"; +import { Image, ChevronRight, ChevronLeft } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; + +interface NFT { + name: string; + collectionName: string; + description: string; + tokenId: string; + contract: string; + imageUrl?: string; +} + +interface NFTsCardProps { + nfts: NFT[]; + isLoading: boolean; +} + +const NFTsCard: React.FC = ({ nfts, isLoading }) => { + const [currentPage, setCurrentPage] = useState(1); + const nftsPerPage = 2; + + const indexOfLastNFT = currentPage * nftsPerPage; + const indexOfFirstNFT = indexOfLastNFT - nftsPerPage; + const currentNFTs = nfts.slice(indexOfFirstNFT, indexOfLastNFT); + const totalPages = Math.ceil(nfts.length / nftsPerPage); + + const nextPage = () => { + if (currentPage < totalPages) setCurrentPage(currentPage + 1); + }; + + const prevPage = () => { + if (currentPage > 1) setCurrentPage(currentPage - 1); + }; + + return ( +
+
+
+
+ +
+

NFT Collection

+
+ {nfts.length > 0 && ( +
+ {indexOfFirstNFT + 1}-{Math.min(indexOfLastNFT, nfts.length)} of {nfts.length} +
+ )} +
+ +
+ {isLoading ? ( + [...Array(2)].map((_, i) => ( + + )) + ) : nfts.length > 0 ? ( + currentNFTs.map((nft, index) => ( +
+
+ {nft.imageUrl ? ( + {nft.name} { + const target = e.target as HTMLImageElement | null; + if (target) { + target.style.display = "none"; + const nextSibling = target.nextSibling as HTMLElement | null; + if (nextSibling) { + nextSibling.style.display = "block"; + } + } + }} + + /> + ) : ( +
+ +
+ )} +
+
+
+

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

+

{nft.collectionName || "Unknown Collection"}

+

+ {nft.description || "No description available"} +

+

+ Token ID: {nft.tokenId.length > 8 ? `${nft.tokenId.substring(0, 8)}...` : nft.tokenId} +

+
+
+ )) + ) : ( +
+ +

No NFTs found for this wallet

+
+ )} +
+ + {nfts.length > nftsPerPage && ( +
+ + +
+ )} +
+ ); +}; + +export default NFTsCard; \ No newline at end of file diff --git a/components/portfolio/TokenCard.tsx b/components/portfolio/TokenCard.tsx new file mode 100644 index 0000000..f58d5be --- /dev/null +++ b/components/portfolio/TokenCard.tsx @@ -0,0 +1,121 @@ +"use client"; +import React, { useState } from "react"; +import { Coins, ChevronLeft, ChevronRight } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; + +interface Token { + name: string; + symbol: string; + balance: string; // Chuỗi thập phân đã định dạng + tokenAddress: string; + decimals: number; + logo?: string; + value: number; +} + +interface TokensCardProps { + tokens: Token[]; + isLoading: boolean; +} + +const TokensCard: React.FC = ({ tokens, isLoading }) => { + const [currentPage, setCurrentPage] = useState(1); + const tokensPerPage = 3; // Giới hạn 5 token mỗi trang + + // Tính toán chỉ số token hiển thị trên trang hiện tại + const indexOfLastToken = currentPage * tokensPerPage; + const indexOfFirstToken = indexOfLastToken - tokensPerPage; + const currentTokens = tokens.slice(indexOfFirstToken, indexOfLastToken); + const totalPages = Math.ceil(tokens.length / tokensPerPage); + + // Hàm chuyển trang + const nextPage = () => { + if (currentPage < totalPages) setCurrentPage(currentPage + 1); + }; + + const prevPage = () => { + if (currentPage > 1) setCurrentPage(currentPage - 1); + }; + + return ( +
+
+
+ +
+

Tokens

+
+ +
+ {isLoading ? ( + [...Array(5)].map((_, i) => ( + + )) + ) : tokens.length > 0 ? ( + currentTokens.map((token, index) => ( +
+
+ {token.logo ? ( + {token.symbol} (e.currentTarget.src = "/placeholder-token.png")} + /> + ) : ( + + )} +
+
+
{token.name}
+
+ {parseFloat(token.balance).toFixed(4)} {token.symbol} +
+
+
+ )) + ) : ( +
+ +

No tokens found

+
+ )} +
+ + {/* Nút chuyển trang */} + {tokens.length > tokensPerPage && ( +
+ +
+ Page {currentPage} of {totalPages} +
+ +
+ )} +
+ ); +}; + +export default TokensCard; \ No newline at end of file diff --git a/components/portfolio/WalletSearch.tsx b/components/portfolio/WalletSearch.tsx new file mode 100644 index 0000000..4a7c9d5 --- /dev/null +++ b/components/portfolio/WalletSearch.tsx @@ -0,0 +1,93 @@ +"use client"; +import React, { useState } from 'react'; +import { Search, ArrowRight } from 'lucide-react'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { toast } from '@/components/ui/use-toast'; + +interface WalletSearchProps { + onSearch: (address: string) => void; + isLoading: boolean; +} + +const WalletSearch: React.FC = ({ onSearch, isLoading }) => { + const [address, setAddress] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!address.trim()) { + toast({ + title: "Invalid Address", + description: "Please enter a valid wallet address", + variant: "destructive", + }); + return; + } + + if (address.trim().length < 26) { + toast({ + title: "Address too short", + description: "Please enter a complete wallet address", + variant: "destructive", + }); + return; + } + + onSearch(address.trim()); + }; + + return ( +
+
+

+ Wallet Portfolio Scanner +

+

+ Enter an Ethereum wallet address to view its assets and activity in real-time +

+
+ +
+
+
+ +
+ setAddress(e.target.value)} + placeholder="Enter wallet address (0x...)" + className="pl-10 pr-20 py-6 backdrop-blur-xl bg-shark-800/70 border border-amber/20 text-gray-200 placeholder:text-gray-500 focus:border-amber focus:ring-amber w-full" + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + style={{ + boxShadow: isFocused ? '0 0 10px 2px rgba(246, 179, 85, 0.4)' : 'none' + }} + /> + +
+
+
+ ); +}; + +export default WalletSearch; diff --git a/components/portfolio_service/alchemyService.tsx b/components/portfolio_service/alchemyService.tsx new file mode 100644 index 0000000..8e27b92 --- /dev/null +++ b/components/portfolio_service/alchemyService.tsx @@ -0,0 +1,235 @@ +"use client"; +import { ethers } from "ethers"; + +const ALCHEMY_API_KEY = "vHX215j9gH01Qc94rX2eEAsLeYohIu9X"; +const API_BASE_URL = `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`; + +export interface WalletData { + balance: string; + tokens: Token[]; + nfts: NFT[]; + transactions: Transaction[]; +} + +export interface Token { + name: string; + symbol: string; + balance: string; + tokenAddress: string; + decimals: number; + logo?: string; + value: number; +} + +export interface NFT { + name: string; + collectionName: string; + description: string; + tokenId: string; + contract: string; + imageUrl?: string; +} + +export interface Transaction { + hash: string; + timeStamp: string; + from: string; + to: string; + value: string; + isError: string; +} + +const fetchAlchemyApi = async (endpoint: string, params: Record) => { + try { + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: params.method, + params: params.params, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status} - ${response.statusText}`); + } + + const data = await response.json(); + if (data.error) { + console.error("Alchemy API error:", data.error); + throw new Error(`API error: ${data.error.message || "Unknown error"}`); + } + + return data.result; + } catch (error) { + console.error("Fetch Alchemy API failed:", error); + throw error; // Ném lỗi để hàm gọi xử lý + } +}; + +export const getWalletBalance = async (address: string): Promise => { + if (!ethers.utils.isAddress(address)) { + console.warn("Invalid Ethereum address:", address); + return "0"; + } + try { + const result = await fetchAlchemyApi("", { + method: "eth_getBalance", + params: [address, "latest"], + }); + const balance = ethers.utils.formatEther(result || "0"); + console.log(`Balance for ${address}: ${balance} ETH`); + return balance; + } catch (error) { + console.error("Error fetching balance:", error); + return "0"; + } +}; + +export const getWalletTokens = async (address: string): Promise => { + if (!ethers.utils.isAddress(address)) { + console.warn("Invalid Ethereum address:", address); + return []; + } + try { + const tokenBalances = await fetchAlchemyApi("", { + method: "alchemy_getTokenBalances", + params: [address, "DEFAULT_TOKENS"], // Lấy danh sách token mặc định + }); + + if (!tokenBalances?.tokenBalances) return []; + + const tokens = await Promise.all( + tokenBalances.tokenBalances + .filter((token: any) => token.tokenBalance && token.tokenBalance !== "0x0") + .map(async (token: any) => { + const metadata = await fetchAlchemyApi("", { + method: "alchemy_getTokenMetadata", + params: [token.contractAddress], + }); + + const balanceInWei = ethers.BigNumber.from(token.tokenBalance); + const decimals = metadata.decimals || 18; + const balance = ethers.utils.formatUnits(balanceInWei, decimals); + + return { + name: metadata.name || "Unknown Token", + symbol: metadata.symbol || "UNK", + balance, + tokenAddress: token.contractAddress, + decimals, + logo: metadata.logo || undefined, + value: parseFloat(balance) * 0.001, // Giá trị giả lập + }; + }) + ); + console.log(`Tokens for ${address}:`, tokens); + return tokens; + } catch (error) { + console.error("Error fetching tokens:", error); + return []; + } +}; + +export const getWalletNFTs = async (address: string): Promise => { + if (!ethers.utils.isAddress(address)) { + console.warn("Invalid Ethereum address:", address); + return []; + } + try { + const nfts = await fetchAlchemyApi("", { + method: "alchemy_getNFTs", + params: [{ owner: address, withMetadata: true }], // Đúng cú pháp cho alchemy_getNFTs + }); + + if (!nfts?.ownedNfts) return []; + + const nftList = nfts.ownedNfts.map((nft: any) => ({ + name: nft.metadata?.name || nft.title || `NFT #${nft.id.tokenId}`, + collectionName: nft.contract?.name || "Unknown Collection", + description: nft.metadata?.description || nft.description || "", + tokenId: nft.id.tokenId, + contract: nft.contract.address, + imageUrl: nft.metadata?.image || nft.media?.[0]?.gateway || nft.image?.thumbnailUrl, + })); + console.log(`NFTs for ${address}:`, nftList); + return nftList; + } catch (error) { + console.error("Error fetching NFTs:", error); + return []; + } +}; + +export const getWalletTransactions = async (address: string): Promise => { + if (!ethers.utils.isAddress(address)) { + console.warn("Invalid Ethereum address:", address); + return []; + } + try { + const transfersFrom = await fetchAlchemyApi("", { + method: "alchemy_getAssetTransfers", + params: [ + { + fromBlock: "0x0", + toBlock: "latest", + fromAddress: address, + category: ["external"], // Chỉ lấy giao dịch ETH + withMetadata: true, + }, + ], + }); + + const transfersTo = await fetchAlchemyApi("", { + method: "alchemy_getAssetTransfers", + params: [ + { + fromBlock: "0x0", + toBlock: "latest", + toAddress: address, + category: ["external"], + withMetadata: true, + }, + ], + }); + + const allTransfers = [...(transfersFrom?.transfers || []), ...(transfersTo?.transfers || [])]; + if (!allTransfers.length) { + console.log(`No transactions found for ${address}`); + return []; + } + + const txs = allTransfers.map((tx: any) => ({ + hash: tx.hash || "", + timeStamp: + tx.metadata?.blockTimestamp + ? Math.floor(new Date(tx.metadata.blockTimestamp).getTime() / 1000).toString() + : "0", + from: tx.from || "", + to: tx.to || "", + value: tx.value ? ethers.utils.parseEther(tx.value.toString()).toString() : "0", + isError: "0", // Alchemy không cung cấp thông tin lỗi, mặc định là 0 + })); + console.log(`Transactions for ${address}:`, txs); + return txs; + } catch (error) { + console.error("Error fetching transactions:", error); + return []; + } +}; + +export const getWalletData = async (address: string): Promise => { + try { + const [balance, tokens, nfts, transactions] = await Promise.all([ + getWalletBalance(address), + getWalletTokens(address), + getWalletNFTs(address), + getWalletTransactions(address), + ]); + return { balance, tokens, nfts, transactions }; + } catch (error) { + console.error("Error fetching wallet data:", error); + return { balance: "0", tokens: [], nfts: [], transactions: [] }; + } +}; \ No newline at end of file diff --git a/lib/api/alchemyNFTApi.ts b/lib/api/alchemyNFTApi.ts new file mode 100644 index 0000000..7c7f6d5 --- /dev/null +++ b/lib/api/alchemyNFTApi.ts @@ -0,0 +1,341 @@ +import { toast } from "sonner"; + +const ALCHEMY_API_KEY = process.env.NEXT_PUBLIC_ALCHEMY_API_KEY || 'demo'; + +const CHAIN_ID_TO_NETWORK: Record = { + '0x1': 'eth-mainnet', + '0x5': 'eth-goerli', + '0xaa36a7': 'eth-sepolia', + '0x89': 'polygon-mainnet', + '0x13881': 'polygon-mumbai', + '0xa': 'optimism-mainnet', + '0xa4b1': 'arbitrum-mainnet', + '0x38': 'bsc-mainnet', +}; + +interface AlchemyNFTResponse { + ownedNfts: any[]; + totalCount: number; + pageKey?: string; +} + +interface CollectionMetadata { + name: string; + symbol: string; + totalSupply: string; + description: string; + imageUrl: string; +} + +export async function fetchUserNFTs(address: string, chainId: string, pageKey?: string): Promise { + 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'; + + 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 }; + } +} + +export async function fetchCollectionInfo(contractAddress: string, chainId: string): Promise { + if (!contractAddress) { + throw new Error("Contract address is required"); + } + + const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; + + try { + 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(`API request failed with status ${response.status}`); + } + + const data = await response.json(); + + return { + name: data.contractMetadata.name || 'Unknown Collection', + symbol: data.contractMetadata.symbol || '', + totalSupply: data.contractMetadata.totalSupply || '0', + description: data.contractMetadata.openSea?.description || '', + imageUrl: data.contractMetadata.openSea?.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: '', + }; + } +} + +interface NFTItem { + id: { + tokenId: string; + }; + title?: string; + description?: string; + media?: Array<{gateway?: string}>; + metadata?: { + attributes?: Array<{trait_type: string, value: string}> + }; +} + +interface CollectionNFT { + id: string; + tokenId: string; + name: string; + description: string; + imageUrl: string; + attributes: Array<{ + trait_type: string; + value: string; + }>; +} + +interface CollectionNFTsResponse { + nfts: CollectionNFT[]; + totalCount: number; + pageKey?: string; +} + +export async function fetchCollectionNFTs( + contractAddress: string, + chainId: string, + page: number = 1, + pageSize: number = 20, + sortBy: string = 'tokenId', + sortDirection: 'asc' | 'desc' = 'asc', + searchQuery: string = '', + attributes: Record = {} +): Promise { + if (!contractAddress) { + throw new Error("Contract address is required"); + } + + const network = CHAIN_ID_TO_NETWORK[chainId as keyof typeof CHAIN_ID_TO_NETWORK] || 'eth-mainnet'; + + try { + 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}`); + } + + const data = await response.json(); + + // Process NFTs + let nfts = data.nfts.map((nft: NFTItem) => ({ + id: `${contractAddress}-${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 || [], + })); + + // Apply filters + 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 (Object.keys(attributes).length > 0) { + nfts = nfts.filter((nft: CollectionNFT) => { + for (const [traitType, values] of Object.entries(attributes)) { + const nftAttribute = nft.attributes.find((attr: {trait_type: string, value: string}) => 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 idA = parseInt(a.tokenId, 16) || 0; + const idB = parseInt(b.tokenId, 16) || 0; + return sortDirection === 'asc' ? idA - idB : idB - idA; + } else if (sortBy === 'name') { + return sortDirection === 'asc' + ? a.name.localeCompare(b.name) + : b.name.localeCompare(a.name); + } + return 0; + }); + + return { + nfts: nfts, + totalCount: data.totalCount || nfts.length, + pageKey: data.pageKey + }; + } catch (error) { + console.error(`Error fetching NFTs for collection ${contractAddress}:`, error); + toast.error("Failed to load collection NFTs"); + return { nfts: [], totalCount: 0 }; + } +} + +// 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 { + // In a production app, this would fetch from Alchemy API + // For this demo, we'll return mock data + return [ + { + id: "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", + name: "Bored Ape Yacht Club", + totalSupply: 10000, + floorPrice: "30.5", + imageUrl: "https://i.seadn.io/gae/Ju9CkWtV-1Okvf45wo8UctR-M9He2PjILP0oOvxE89AyiPPGtrR3gysu1Zgy0hjd2xKIgjJJtWIc0ybj4Vd7wv8t3pxDGHoJBzDB?auto=format&dpr=1&w=1000" + }, + { + id: "0x60e4d786628fea6478f785a6d7e704777c86a7c6", + name: "Mutant Ape Yacht Club", + totalSupply: 19423, + floorPrice: "10.2", + imageUrl: "https://i.seadn.io/gae/lHexKRMpw-aoSyB1WdFBff5yfANLReFxHzt1DOj_sg7mS14yARpuvYcUtsyyx-Nkpk6WTcUPF6rLh2D4Xw?auto=format&dpr=1&w=1000" + }, + { + id: "0xed5af388653567af2f388e6224dc7c4b3241c544", + name: "Azuki", + totalSupply: 10000, + floorPrice: "8.75", + imageUrl: "https://i.seadn.io/gae/H8jOCJuQokNqGBpkBN5wk1oZwO7LM8bNnrHCaekV2nKjnCqw6UB5oaH8XyNeBDj6bA_n1mjejzhFQUP3O1NfjFLHr3FOaeHcTOOT?auto=format&dpr=1&w=1000" + }, + { + id: "0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb", + name: "CryptoPunks", + totalSupply: 10000, + floorPrice: "54.95", + imageUrl: "https://i.seadn.io/gae/BdxvLseXcfl57BiuQcQYdJ64v-aI8din7WPk0Pgo3qQFhAUH-B6i-dCqqc_mCkRIzULmwzwecnohLhrcH8A9mpWIZqA7ygc52Sr81hE?auto=format&dpr=1&w=1000" + }, + { + id: "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", + name: "Doodles", + totalSupply: 10000, + floorPrice: "5.25", + imageUrl: "https://i.seadn.io/gae/7B0qai02OdHA8P_EOVK672qUliyjQdQDGNrACxs7WnTgZAkJa_wWURnIFKeOh5VTf8cfTqW3wQpozGedaC9mteKphEOtztls02RlWQ?auto=format&dpr=1&w=1000" + }, + { + id: "0xdf5d4038723f6605a3ecd7776ffe25f3b1be39a0", + name: "PATH NFT Collection", + totalSupply: 1000, + floorPrice: "0.5", + imageUrl: "/images/path-token.png" + } + ]; +} + +// Function to fetch marketplace trading history +export async function fetchTradeHistory(tokenId?: string): Promise { + // This would normally connect to a blockchain indexer service + // For now, we'll return mock data + return [ + { + id: '1', + event: 'Sale', + tokenId: tokenId || '123', + from: '0x1234567890abcdef1234567890abcdef12345678', + to: '0xabcdef1234567890abcdef1234567890abcdef12', + price: '120.5', + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), // 2 hours ago + txHash: '0xabc123def456' + }, + { + id: '2', + event: 'Transfer', + tokenId: tokenId || '123', + from: '0xabcdef1234567890abcdef1234567890abcdef12', + to: '0x9876543210abcdef1234567890abcdef12345678', + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 1 day ago + txHash: '0xdef456abc789' + }, + { + id: '3', + event: 'Mint', + tokenId: tokenId || '123', + from: '0x0000000000000000000000000000000000000000', + to: '0x1234567890abcdef1234567890abcdef12345678', + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(), // 3 days ago + txHash: '0x789abc123def' + }, + { + id: '4', + event: 'List', + tokenId: tokenId || '123', + from: '0x1234567890abcdef1234567890abcdef12345678', + to: '0x0000000000000000000000000000000000000000', + price: '100', + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), // 2 days ago + txHash: '0x456def789abc' + } + ]; +} + +// Function to fetch price history data for charts +export async function fetchPriceHistory(tokenId?: string): Promise { + // This would normally fetch real historical price data + // For now, generate some mock data + const now = Date.now(); + const data = []; + + // Generate 30 days of price data + for (let i = 30; i >= 0; i--) { + const date = new Date(now - 1000 * 60 * 60 * 24 * i); + const basePrice = tokenId ? 100 : 120; // Different base for collection vs single NFT + const randomFactor = 0.3 * Math.sin(i / 2) + 0.2 * Math.cos(i); + const volatility = 0.1; + + data.push({ + date: date.toISOString().split('T')[0], + price: basePrice * (1 + randomFactor + volatility * (Math.random() - 0.5)) + }); + } + + return data; +} diff --git a/public/Img/market-overview.png b/public/Img/market-overview.png new file mode 100644 index 0000000..4cb7057 Binary files /dev/null and b/public/Img/market-overview.png differ diff --git a/services/apiCache.ts b/services/apiCache.ts new file mode 100644 index 0000000..b085333 --- /dev/null +++ b/services/apiCache.ts @@ -0,0 +1,46 @@ +interface CacheItem { + data: T; + timestamp: number; +} + +interface CacheConfig { + globalMarketData: number; // 5 minutes + topCryptos: number; // 5 minutes + historicalData: number; // 30 minutes +} + +class APICache { + private cache: Map>; + private config: CacheConfig; + + constructor() { + this.cache = new Map(); + this.config = { + globalMarketData: 5 * 60 * 1000, // 5 minutes + topCryptos: 5 * 60 * 1000, // 5 minutes + historicalData: 30 * 60 * 1000 // 30 minutes + }; + } + + get(key: string, category: keyof CacheConfig): T | null { + const item = this.cache.get(key); + if (!item) return null; + + const ttl = this.config[category]; + if (Date.now() - item.timestamp > ttl) { + this.cache.delete(key); + return null; + } + + return item.data; + } + + set(key: string, data: T): void { + this.cache.set(key, { + data, + timestamp: Date.now() + }); + } +} + +export const apiCache = new APICache(); \ No newline at end of file diff --git a/services/binanceApiService.ts b/services/binanceApiService.ts new file mode 100644 index 0000000..cebaf30 --- /dev/null +++ b/services/binanceApiService.ts @@ -0,0 +1,83 @@ +import axios from 'axios'; + +const BINANCE_API_URL = 'https://api.binance.com/api/v3'; + +interface BinanceKline { + openTime: number; + open: string; + high: string; + low: string; + close: string; + volume: string; + closeTime: number; + quoteAssetVolume: string; + trades: number; + takerBuyBaseAssetVolume: string; + takerBuyQuoteAssetVolume: string; + ignored: string; +} + +/** + * Convert symbol to Binance trading pair format + */ +const getBinancePair = (symbol: string): string => { + // Convert common symbols to Binance format (BTCUSDT, ETHUSDT, etc.) + return `${symbol.toUpperCase()}USDT`; +}; + +/** + * Get historical price data from Binance + */ +export const fetchHistoricalPriceData = async (symbol: string, days: number = 30): Promise => { + try { + const interval = days <= 7 ? '1h' : '1d'; + const limit = days <= 7 ? days * 24 : days; + + const pair = getBinancePair(symbol); + + const response = await axios.get(`${BINANCE_API_URL}/klines`, { + params: { + symbol: pair, + interval, + limit: Math.min(limit, 1000) // Binance has a limit of 1000 candles + } + }); + + if (!response.data || !Array.isArray(response.data)) { + throw new Error('Invalid response from Binance API'); + } + + // Transform to the format our charts expect - using closing price (index 4) + const prices = response.data.map((kline: any) => [kline[0], parseFloat(kline[4])]); + + // Use actual traded volume for better accuracy + const volumes = response.data.map((kline: any) => [kline[0], parseFloat(kline[5]) * parseFloat(kline[4])]); + + // For market caps, we estimate based on circulating supply + const market_cap_multiplier = getMarketCapMultiplier(symbol); + const market_caps = prices.map(([time, price]) => [time, price * market_cap_multiplier]); + + return { + prices, + market_caps, + total_volumes: volumes + }; + } catch (error) { + console.error('Error fetching data from Binance:', error); + throw error; + } +}; + +/** + * Helper to estimate market cap based on approximate circulating supply + */ +function getMarketCapMultiplier(symbol: string): number { + // Rough estimates of circulating supply for major coins + switch (symbol.toLowerCase()) { + case 'btc': return 19500000; // ~19.5M BTC in circulation + case 'eth': return 120000000; // ~120M ETH in circulation + case 'bnb': return 153000000; // ~153M BNB in circulation + case 'sol': return 430000000; // ~430M SOL in circulation + default: return 100000000; // Default fallback + } +} diff --git a/services/coinMarketCapService.ts b/services/coinMarketCapService.ts new file mode 100644 index 0000000..6033bc9 --- /dev/null +++ b/services/coinMarketCapService.ts @@ -0,0 +1,286 @@ +import axios from 'axios'; +import { apiCache } from './apiCache'; + +// Configuration +const MAX_RETRIES = 3; +const INITIAL_RETRY_DELAY = 1000; // 1 second +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes +const API_PROXY_URL = '/api/coinmarketcap'; + +// Interface definitions +export interface GlobalMarketData { + total_market_cap: { [key: string]: number }; + total_volume: { [key: string]: number }; + market_cap_percentage: { [key: string]: number }; + market_cap_change_percentage_24h_usd: number; + active_cryptocurrencies: number; + markets: number; + last_updated: string; +} + +export interface TokenData { + id: number; + name: string; + symbol: string; + slug: string; + cmc_rank: number; + quote: { + USD: { + price: number; + volume_24h: number; + market_cap: number; + percent_change_1h: number; + percent_change_24h: number; + percent_change_7d: number; + percent_change_30d: number; + last_updated: string; + } + } +} + +// Helper functions +async function retryWithBackoff( + operation: () => Promise, + retries = MAX_RETRIES, + delay = INITIAL_RETRY_DELAY +): Promise { + try { + return await operation(); + } catch (error: any) { + if (retries === 0 || (error.response && error.response.status !== 429)) { + throw error; + } + + console.warn(`Request failed, retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + + return retryWithBackoff(operation, retries - 1, delay * 2); + } +} + +// Data conversion functions +const convertToGlobalMarketData = (response: any): GlobalMarketData => { + try { + console.log('API Response:', JSON.stringify(response, null, 2)); + + if (response.status && response.status.error_code) { + throw new Error(`API Error: ${response.status.error_message}`); + } + + const data = response.data || response; + + return { + total_market_cap: { + usd: data.total_market_cap?.usd || + data.quote?.USD?.total_market_cap || + data.total_market_cap || 0 + }, + total_volume: { + usd: data.total_volume?.usd || + data.quote?.USD?.total_volume_24h || + data.total_volume || 0 + }, + market_cap_percentage: { + btc: data.market_cap_percentage?.btc || + data.btc_dominance || + data.dominance?.btc || 0, + eth: data.market_cap_percentage?.eth || + data.eth_dominance || + data.dominance?.eth || 0, + }, + market_cap_change_percentage_24h_usd: + data.market_cap_change_percentage_24h_usd || + data.quote?.USD?.total_market_cap_yesterday_percentage_change || 0, + active_cryptocurrencies: + data.active_cryptocurrencies || + data.total_cryptocurrencies || 0, + markets: + data.markets || + data.active_market_pairs || 0, + last_updated: + data.last_updated || + new Date().toISOString() + }; + } catch (error) { + console.error('Error converting global market data:', error); + return getSimulatedGlobalData(); + } +}; + +const convertToStandardTokenData = (tokens: TokenData[]): any[] => { + try { + return tokens.map(token => { + if (!token.quote || !token.quote.USD) { + throw new Error(`Invalid token data structure for token: ${token.id}`); + } + + return { + id: token.id?.toString() || '0', + name: token.name || 'Unknown', + symbol: token.symbol || 'N/A', + current_price: token.quote.USD.price || 0, + market_cap: token.quote.USD.market_cap || 0, + total_volume: token.quote.USD.volume_24h || 0, + price_change_percentage_24h: token.quote.USD.percent_change_24h || 0, + price_change_percentage_7d: token.quote.USD.percent_change_7d || 0, + price_change_percentage_30d: token.quote.USD.percent_change_30d || 0, + market_cap_rank: token.cmc_rank || 0 + }; + }).filter(token => token.name !== 'Unknown'); + } catch (error) { + console.error('Error converting token data:', error); + return []; + } +}; + +// API functions +export const fetchGlobalMarketData = async (): Promise => { + const cachedData = apiCache.get('globalMarketData', 'globalMarketData'); + if (cachedData) { + return cachedData; + } + + try { + const response = await retryWithBackoff(async () => { + return await axios.get(`${API_PROXY_URL}/global-metrics`); + }); + + const data = convertToGlobalMarketData(response.data); + apiCache.set('globalMarketData', data); + return data; + } catch (error) { + console.error('Error fetching global market data:', error); + const fallbackData = getSimulatedGlobalData(); + apiCache.set('globalMarketData', fallbackData); + return fallbackData; + } +}; + +export const fetchTopCryptocurrencies = async (limit: number = 10): Promise => { + const cacheKey = `topCryptos_${limit}`; + const cachedData = apiCache.get(cacheKey, 'topCryptos'); + if (cachedData) { + return cachedData; + } + + try { + const response = await retryWithBackoff(async () => { + return await axios.get(`${API_PROXY_URL}/listings`, { + params: { + limit, + sort: 'market_cap', + sort_dir: 'desc', + } + }); + }); + + const data = convertToStandardTokenData(response.data.data); + apiCache.set(cacheKey, data); + return data; + } catch (error) { + console.error('Error fetching top cryptocurrencies:', error); + const fallbackData = getSimulatedTopTokens(limit); + apiCache.set(cacheKey, fallbackData); + return fallbackData; + } +}; + +export const fetchHistoricalData = async ( + symbol: string, + days: number = 30, + interval: string = 'daily' +): Promise => { + const cacheKey = `historicalData_${symbol}_${days}`; + const cachedData = apiCache.get(cacheKey, 'historicalData'); + if (cachedData) { + return cachedData; + } + + try { + // Try to get data from Binance API if possible + const binanceData = await retryWithBackoff(async () => { + const binanceApi = await import('@/services/binanceApiService'); + return await binanceApi.fetchHistoricalPriceData(symbol, days); + }).catch(() => null); + + if (binanceData) { + apiCache.set(cacheKey, binanceData); + return binanceData; + } + + // Fallback to simulated data + console.warn(`Using simulated data for ${symbol} historical prices`); + const fallbackData = getSimulatedHistoricalData(symbol, days); + apiCache.set(cacheKey, fallbackData); + return fallbackData; + } catch (error) { + console.error(`Error fetching historical data for ${symbol}:`, error); + const fallbackData = getSimulatedHistoricalData(symbol, days); + apiCache.set(cacheKey, fallbackData); + return fallbackData; + } +}; + +// Simulated data generators +function getSimulatedGlobalData(): GlobalMarketData { + return { + total_market_cap: { + usd: 2300000000000 // $2.3T + }, + total_volume: { + usd: 115000000000 // $115B + }, + market_cap_percentage: { + btc: 48.5, + eth: 17.3, + }, + market_cap_change_percentage_24h_usd: 2.34, + active_cryptocurrencies: 10423, + markets: 814, + last_updated: new Date().toISOString() + }; +} + +function getSimulatedTopTokens(limit: number): any[] { + const mockTokens = [ + { id: '1', name: 'Bitcoin', symbol: 'btc', current_price: 64253.12, price_change_percentage_24h: 2.41, market_cap: 1260000000000, market_cap_rank: 1 }, + { id: '2', name: 'Ethereum', symbol: 'eth', current_price: 3427.81, price_change_percentage_24h: 1.58, market_cap: 412000000000, market_cap_rank: 2 }, + { id: '3', name: 'Tether', symbol: 'usdt', current_price: 0.9998, price_change_percentage_24h: 0.01, market_cap: 110000000000, market_cap_rank: 3 }, + { id: '4', name: 'BNB', symbol: 'bnb', current_price: 587.33, price_change_percentage_24h: 0.92, market_cap: 85000000000, market_cap_rank: 4 }, + { id: '5', name: 'Solana', symbol: 'sol', current_price: 143.38, price_change_percentage_24h: 3.76, market_cap: 65000000000, market_cap_rank: 5 }, + ]; + + return mockTokens.slice(0, limit); +} + +function getSimulatedHistoricalData(symbol: string, days: number): any { + const basePrice = symbol.toLowerCase() === 'btc' ? 68000 : + symbol.toLowerCase() === 'eth' ? 3500 : + symbol.toLowerCase() === 'sol' ? 140 : + symbol.toLowerCase() === 'bnb' ? 600 : 100; + + const prices = []; + const market_caps = []; + const total_volumes = []; + + const now = Date.now(); + const oneDayMs = 24 * 60 * 60 * 1000; + + let currentPrice = basePrice; + + for (let i = days; i >= 0; i--) { + const timestamp = now - (i * oneDayMs); + const randomChange = currentPrice * (Math.random() * 0.06 - 0.03); // -3% to +3% + currentPrice += randomChange; + + prices.push([timestamp, currentPrice]); + market_caps.push([timestamp, currentPrice * 19500000]); // Simulated market cap + total_volumes.push([timestamp, currentPrice * 500000 * (0.7 + Math.random() * 0.6)]); // Simulated volume + } + + return { + prices, + market_caps, + total_volumes + }; +}