diff --git a/README.md b/README.md index df6990d..6928208 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ npm install next --legacy-peer-deps # Set up environment variables touch .env.local -``` +```s Populate `.env.local` with: ```dotenv ETHERSCAN_API_KEY=YOUR_API_KEY @@ -36,6 +36,14 @@ SMTP_PASSWORD=your-password NEO4J_URI=neo4j+s://your-database-uri NEO4J_USERNAME=your-username NEO4J_PASSWORD=your-password +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 @@ -53,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 317c4e9..4382bd1 100644 --- a/app/NFT/page.tsx +++ b/app/NFT/page.tsx @@ -4,14 +4,20 @@ import { ethers } from 'ethers'; import NFTCard from '@/components/NFT/NFTCard'; import NFTTabs from '@/components/NFT/NFTTabs'; import Pagination from '@/components/NFT/Pagination'; +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 NFTMarketStats from '@/components/NFT/NFTMarketStats'; +import PriceChart from '@/components/NFT/PriceChart'; +import { toast } from '@/hooks/use-toast'; // Contract addresses -const NFT_CONTRACT_ADDRESS = "0x279Bd9304152E0349427c4B7F35FffFD439edcFa"; +const NFT_CONTRACT_ADDRESS = "0x2fF12fE4B3C4DEa244c4BdF682d572A90Df3B551"; const PATH_TOKEN_ADDRESS = "0xc3e9Cf26237c9002c0C04305D637AEa3d9A4A1DE"; const ITEMS_PER_PAGE = 8; -// ABI definitions +// Updated ABI with whitelist functions const NFT_ABI = [ "function totalSupply() view returns (uint256)", "function ownerOf(uint256 tokenId) view returns (address)", @@ -20,7 +26,11 @@ const NFT_ABI = [ "function listings(uint256) view returns (uint256 price, address seller, bool isListed)", "function buyNFT(uint256 tokenId) external", "function listNFT(uint256 tokenId, uint256 price) external", - "function unlistNFT(uint256 tokenId) external" + "function unlistNFT(uint256 tokenId) external", + "function mintNFT(address to, string memory tokenURI) external", + "function owner() view returns (address)", + "function updateWhitelist(address _account, bool _status) external", + "function whitelist(address) view returns (bool)" ]; const TOKEN_ABI = [ @@ -29,7 +39,6 @@ const TOKEN_ABI = [ "function balanceOf(address account) external view returns (uint256)" ]; -// Ethereum provider type declaration declare global { interface Window { ethereum?: ethers.providers.ExternalProvider & { @@ -40,20 +49,49 @@ declare global { } } +interface NFTData { + market: any[]; + owned: any[]; + 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'>('market'); + const [activeTab, setActiveTab] = useState<'market' | 'owned' | 'listings' | 'mint' | 'whitelist'>('market'); const [processing, setProcessing] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [pathBalance, setPathBalance] = useState('0.0000'); - const [nftData, setNftData] = useState<{ - market: any[]; - owned: any[]; - listings: any[]; - }>({ market: [], owned: [], listings: [] }); + const [ownerAddress, setOwnerAddress] = useState(''); + const [isWhitelisted, setIsWhitelisted] = useState(false); + const [nftData, setNftData] = useState({ + market: [], + owned: [], + 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(), + [account, ownerAddress] + ); - // Ethereum provider handler const getProvider = () => { if (typeof window !== 'undefined' && window.ethereum) { return new ethers.providers.Web3Provider(window.ethereum, "any"); @@ -61,19 +99,87 @@ export default function NFTMarketplace() { throw new Error('Ethereum provider not found'); }; + // Fetch contract owner + useEffect(() => { + const fetchOwner = async () => { + try { + const provider = getProvider(); + const contract = new ethers.Contract(NFT_CONTRACT_ADDRESS, NFT_ABI, provider); + const owner = await contract.owner(); + setOwnerAddress(owner.toLowerCase()); + } catch (error) { + console.error("Error fetching contract owner:", error); + } + }; + fetchOwner(); + }, []); + + // Check whitelist status + const checkWhitelistStatus = useCallback(async (address: string) => { + try { + const provider = getProvider(); + const contract = new ethers.Contract(NFT_CONTRACT_ADDRESS, NFT_ABI, provider); + return await contract.whitelist(address); + } catch (error) { + console.error("Error checking whitelist:", error); + return false; + } + }, []); + + useEffect(() => { + const checkUserWhitelist = async () => { + if (account) { + const status = await checkWhitelistStatus(account); + setIsWhitelisted(status); + } + }; + checkUserWhitelist(); + }, [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; @@ -81,11 +187,10 @@ export default function NFTMarketplace() { const provider = getProvider(); const contract = new ethers.Contract(NFT_CONTRACT_ADDRESS, NFT_ABI, provider); - // Get listed NFTs const listedIds = await contract.getAllListings().catch(() => []); - - // Process 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([ @@ -94,7 +199,8 @@ export default function NFTMarketplace() { 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, @@ -110,74 +216,149 @@ export default function NFTMarketplace() { }) ); - // Get 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 = []; + + 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; + } + }) + ); - const ownedNFTs = await Promise.all( - allIds.map(async (id) => { - try { - const [owner, listing] = await Promise.all([ - contract.ownerOf(id).catch(() => '0x0'), - contract.listings(id) - ]); + ownedNFTs.push(...pageResults + .filter(result => result.status === 'fulfilled' && result.value !== null) + .map(result => (result as PromiseFulfilledResult).value) + ); + } - 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 - }; - } - return null; - } catch (error) { - return null; - } - }) - ); + const validMarketNFTs = marketNFTs + .filter(result => result.status === 'fulfilled' && result.value !== null) + .map(result => (result as PromiseFulfilledResult).value); - // Update state 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]); + useEffect(() => { + if (account) { + fetchNFTs(); + const interval = setInterval(fetchNFTs, 15000); + return () => clearInterval(interval); + } + }, [account, fetchNFTs]); + + useEffect(() => { + setCurrentPage(1); + }, [activeTab]); + + useEffect(() => { + if (account) { + fetchPathBalance(account); + } + }, [account, fetchPathBalance]); + // Pagination const paginatedData = useMemo(() => { + if (activeTab === 'mint' || activeTab === 'whitelist') return []; const start = (currentPage - 1) * ITEMS_PER_PAGE; const end = start + ITEMS_PER_PAGE; return nftData[activeTab].slice(start, end); }, [nftData, activeTab, currentPage]); - - const totalPages = useMemo(() => - Math.ceil(nftData[activeTab].length / ITEMS_PER_PAGE), - [nftData, activeTab] - ); + + const totalPages = useMemo(() => { + if (activeTab === 'mint' || activeTab === 'whitelist') return 0; + return Math.ceil(nftData[activeTab].length / ITEMS_PER_PAGE); + }, [nftData, activeTab]); const handlePageChange = (page: number) => { setCurrentPage(Math.max(1, Math.min(page, totalPages))); }; - // Refresh data after transactions + // Refresh data const refreshData = async () => { await fetchNFTs(); if (account) await fetchPathBalance(account); }; + // Handle mint NFT + const handleMintNFT = async (recipient: string, tokenURI: string) => { + if (!account) return; // Nếu không có tài khoản, thoát + if (!isOwner && !isWhitelisted) { // Nếu không phải owner và không whitelist, báo lỗi + toast({ + title: "Error", + description: "You are not authorized to mint NFTs", + variant: "destructive" + }); + return; + } + setProcessing(true); + try { + const provider = getProvider(); + const signer = provider.getSigner(); + const contract = new ethers.Contract(NFT_CONTRACT_ADDRESS, NFT_ABI, signer); + + await fetchMetadata(tokenURI); // Xác thực URI trước khi mint + 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); + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Mint failed", + variant: "destructive" + }); + } finally { + setProcessing(false); + } + }; + // Handle buy NFT const handleBuyNFT = async (tokenId: string, price: string) => { if (!account) { @@ -217,7 +398,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; } @@ -239,9 +424,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); } @@ -250,7 +444,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; } @@ -270,75 +468,67 @@ 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); } }; - // Effects - useEffect(() => { - if (account) { - fetchNFTs(); - const interval = setInterval(fetchNFTs, 15000); - return () => clearInterval(interval); - } - }, [account, fetchNFTs]); + // Handle update whitelist + const handleUpdateWhitelist = async (address: string, status: boolean) => { + if (!account || !isOwner) return; - useEffect(() => { - setCurrentPage(1); - }, [activeTab]); - - useEffect(() => { - if (account) { - fetchPathBalance(account); + try { + setProcessing(true); + const provider = getProvider(); + const signer = provider.getSigner(); + const contract = new ethers.Contract(NFT_CONTRACT_ADDRESS, NFT_ABI, signer); + + const tx = await contract.updateWhitelist(address, status); + await tx.wait(); + toast({ + title: "Success", + description: "Whitelist updated successfully!", + variant: "default" + }); + } catch (error) { + console.error("Whitelist update failed:", error); + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Update failed", + variant: "destructive" + }); + } finally { + setProcessing(false); } - }, [account, fetchPathBalance]); + }; return ( -
- {/* Background effects */} - -
-

- NFT Marketplace -

-
- {account && ( -
- - {pathBalance} - - PATH -
- )} - -
-
- +
+
+

Trade exclusive NFTs in the PATH ecosystem using PATH tokens

+
+ + {/* Market Statistics */} + + + {/* Price Chart */} +
+ +
+ + {/* Tabs Navigation */} {!account ? ( -
+
Please connect your wallet to view NFTs
) : ( @@ -360,41 +552,72 @@ export default function NFTMarketplace() { {[...Array(8)].map((_, i) => (
))}
) : ( <> -
- {paginatedData.map((nft, index) => ( -
- handleBuyNFT(tokenId, price || '0') : - activeTab === 'owned' ? (tokenId, price) => handleListNFT(tokenId, price || '0') : - handleUnlistNFT - } + {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 && ( -
- -
+ + {totalPages > 1 && ( +
+ +
+ )} + )} )} @@ -402,8 +625,8 @@ export default function NFTMarketplace() { )} {processing && ( -
-
+
+

Processing Transaction

@@ -420,4 +643,4 @@ export default function NFTMarketplace() { )}
); -} \ 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/dappradar-trending-nft/route.ts b/app/api/dappradar-trending-nft/route.ts new file mode 100644 index 0000000..e82ef26 --- /dev/null +++ b/app/api/dappradar-trending-nft/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; + + export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const chain = searchParams.get('chain') || 'ethereum'; + const API_KEY = process.env.REACT_APP_DAPPRADAR_API_KEY; + + try { + const nftResponse = await fetch(`https://apis.dappradar.com/v2/nfts/collections?range=24h&sort=sales&order=desc&chain=${chain}&resultsPerPage=10`, { + headers: { + accept: 'application/json', + 'x-api-key': API_KEY || '', + }, + } + ); + + + // Check if any request failed. + if (!nftResponse.ok) { + return NextResponse.json( + { + error: `Returned an error: dapps(${nftResponse.status}))`, + }, + { status: 404 } + ); + } + + const nftData = await nftResponse.json(); + + return NextResponse.json({ + nfts: nftData, + }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { status: 500 } + ); + } + } diff --git a/app/api/dappradar-trending-project/route.ts b/app/api/dappradar-trending-project/route.ts new file mode 100644 index 0000000..16dd702 --- /dev/null +++ b/app/api/dappradar-trending-project/route.ts @@ -0,0 +1,56 @@ +// api/dappradar/route.ts +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const chain = searchParams.get('chain') || 'ethereum'; + const API_KEY = process.env.REACT_APP_DAPPRADAR_API_KEY; + + try { + const [dappsResponse, gamesResponse, marketplaceResponse] = await Promise.all([ + fetch(`https://apis.dappradar.com/v2/dapps/top/uaw?chain=${chain}&range=7d&top=10`, { + headers: { + accept: 'application/json', + 'x-api-key': API_KEY || '', + }, + }), + fetch(`https://apis.dappradar.com/v2/dapps/top/balance?chain=${chain}&category=games&range=24h&top=10`, { + headers: { + accept: 'application/json', + 'x-api-key': API_KEY || '', + }, + }), + fetch(`https://apis.dappradar.com/v2/dapps/top/transactions?chain=${chain}&category=marketplaces&range=24h&top=10`, { + headers: { + accept: 'application/json', + 'x-api-key': API_KEY || '', + }, + }), + ]); + + // Check if any request failed. + if (!dappsResponse.ok || !gamesResponse.ok || !marketplaceResponse.ok) { + return NextResponse.json( + { + error: `One or more endpoints returned an error: dapps(${dappsResponse.status}), game(${gamesResponse.status}), arketplace(${marketplaceResponse.status})`, + }, + { status: 404 } + ); + } + + const dappsData = await dappsResponse.json(); + const gamesData = await gamesResponse.json(); + const marketplacesData = await marketplaceResponse.json(); + + return NextResponse.json({ + dapps: dappsData, + games: gamesData, + marketplaces: marketplacesData, + }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/etherscan/route.ts b/app/api/etherscan/route.ts index 7d8bac3..2a46e51 100644 --- a/app/api/etherscan/route.ts +++ b/app/api/etherscan/route.ts @@ -1,16 +1,68 @@ import { NextResponse } from "next/server" +// Simple in-memory cache +const cache = new Map(); +const CACHE_DURATION = 5000; // 5 seconds cache +let lastCallTimestamp = 0; +const RATE_LIMIT_WINDOW = 200; // 200ms between calls (5 calls per second) + export async function GET(request: Request) { const { searchParams } = new URL(request.url) - const moduleParam = searchParams.get("module") - const action = searchParams.get("action") - const url = `https://api.etherscan.io/api?module=${moduleParam}&action=${action}&apikey=${process.env.ETHERSCAN_API_KEY}` + // Create cache key from the entire URL + const cacheKey = searchParams.toString(); + + // Check cache + const cachedData = cache.get(cacheKey); + if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) { + return NextResponse.json(cachedData.data); + } + + // Rate limiting + const now = Date.now(); + if (now - lastCallTimestamp < RATE_LIMIT_WINDOW) { + await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_WINDOW)); + } + lastCallTimestamp = Date.now(); + + // Build the Etherscan API URL with all parameters + const urlParams = new URLSearchParams() + + // Add all search params to the URL + searchParams.forEach((value, key) => { + urlParams.append(key, value) + }) + + // Always include the API key + urlParams.append('apikey', process.env.ETHERSCAN_API_KEY || '') + + const url = `https://api.etherscan.io/api?${urlParams.toString()}` + try { const response = await fetch(url) + if (!response.ok) { + throw new Error(`Etherscan API responded with status: ${response.status}`) + } const data = await response.json() + + // Check for Etherscan API errors + if (data.status === "0" && data.message === "NOTOK") { + throw new Error(data.result) + } + + // Cache the successful response + cache.set(cacheKey, { data, timestamp: Date.now() }); + return NextResponse.json(data) } catch (error) { - return NextResponse.json({ error: "Failed to fetch from Etherscan" }, { status: 500 }) + console.error('Etherscan API error:', error) + return NextResponse.json( + { + status: "0", + message: "NOTOK", + result: error instanceof Error ? error.message : "Failed to fetch from Etherscan" + }, + { status: 500 } + ) } -} +} \ No newline at end of file diff --git a/app/api/infura/route.ts b/app/api/infura/route.ts new file mode 100644 index 0000000..47547da --- /dev/null +++ b/app/api/infura/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from "next/server"; + +const INFURA_API_KEY = process.env.NEXT_PUBLIC_INFURA_KEY; // Will be moved to env process later + +const networkUrls: { mainnet: string; optimism: string; arbitrum: string; } = { + mainnet: `https://mainnet.infura.io/v3/${INFURA_API_KEY}`, + optimism: `https://optimism-mainnet.infura.io/v3/${INFURA_API_KEY}`, + arbitrum: `https://arbitrum-mainnet.infura.io/v3/${INFURA_API_KEY}` +}; + +// Define the network type first +type Network = 'mainnet' | 'optimism' | 'arbitrum'; + +// Simple in-memory cache +const cache = new Map(); +const CACHE_DURATION = 10000; // 10 seconds cache (increased from 5s) +let lastCallTimestamp = 0; +const RATE_LIMIT_WINDOW = 300; // 300ms between calls (reduced from 5 calls to 3-4 calls per second) + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { method, params, network = "mainnet" } = body; + + // Create cache key from the method and params + const cacheKey = `${network}-${method}-${JSON.stringify(params)}`; + + // Check cache + const cachedData = cache.get(cacheKey); + if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) { + return NextResponse.json(cachedData.data); + } + + // Rate limiting + const now = Date.now(); + if (now - lastCallTimestamp < RATE_LIMIT_WINDOW) { + const waitTime = RATE_LIMIT_WINDOW - (now - lastCallTimestamp); + await new Promise(resolve => setTimeout(resolve, waitTime)); + } + lastCallTimestamp = Date.now(); + + // Select network URL + const networkUrl = networkUrls[network as Network] || networkUrls.mainnet; + + // Prepare JSON-RPC request + const rpcRequest = { + jsonrpc: "2.0", + id: 1, + method, + params + }; + + // Call Infura API with NO timeout - let the browser or network naturally timeout + console.log(`Making Infura request: ${method} to ${network}`); + + try { + const response = await fetch(networkUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(rpcRequest) + }); + + if (!response.ok) { + throw new Error(`Infura API responded with status: ${response.status}`); + } + + // First check if the response is JSON + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + throw new Error("Infura API returned a non-JSON response"); + } + + const data = await response.json(); + + // Verify that the data is valid JSON-RPC + if (!data || typeof data !== 'object' || (!data.result && !data.error)) { + throw new Error("Invalid JSON-RPC response from Infura"); + } + + // Cache the successful response + cache.set(cacheKey, { data, timestamp: Date.now() }); + + console.log(`Infura request successful: ${method}`); + return NextResponse.json(data); + } catch (error) { + console.error(`Infura request error for ${method}:`, error); + throw error; + } + } catch (error) { + console.error('Infura API error:', error); + const errorMessage = error instanceof Error + ? error.message + : "Failed to fetch from Infura"; + + return NextResponse.json( + { + jsonrpc: "2.0", + id: 1, + error: { + code: -32603, + message: errorMessage + } + }, + { status: 500 } + ); + } +} 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/api/nfts/route.ts b/app/api/nfts/route.ts index a5b549e..cb6ea8e 100644 --- a/app/api/nfts/route.ts +++ b/app/api/nfts/route.ts @@ -1,61 +1,121 @@ import { NextResponse } from "next/server" -const ETHERSCAN_API_URL = "https://api.etherscan.io/api" +const ALCHEMY_API_URL = "https://eth-mainnet.g.alchemy.com/nft/v2" +const DEFAULT_IPFS_GATEWAY = "https://gateway.pinata.cloud/ipfs/" interface NFT { tokenID: string tokenName: string tokenSymbol: string contractAddress: string + imageUrl: string } -export async function GET(request: Request) { - const { searchParams } = new URL(request.url) - const address = searchParams.get("address") +interface NFTResponse { + nfts: NFT[] + totalCount: number + pageKey?: string | null + error?: string +} - if (!address) { - return NextResponse.json({ error: "Address is required" }, { status: 400 }) +const convertIpfsToGatewayUrl = (url: string): string => { + if (!url) return "/images/nft-placeholder.png" + + // Handle ipfs:// protocol + if (url.startsWith("ipfs://")) { + const cidAndPath = url.replace("ipfs://", "") + return `${DEFAULT_IPFS_GATEWAY}${cidAndPath}` } + + // Already using a gateway, return as is + if (url.includes("ipfs") && + (url.startsWith("http://") || url.startsWith("https://"))) { + return url + } + + return url +} - try { - const response = await fetch( - `${ETHERSCAN_API_URL}?module=account&action=tokennfttx&address=${address}&startblock=0&endblock=99999999&sort=desc&apikey=${process.env.ETHERSCAN_API_KEY}`, - ) +// Enhanced address validation +const isValidAddress = (address: string): boolean => { + // Basic Ethereum address format check + return /^0x[a-fA-F0-9]{40}$/.test(address); +}; +export async function GET(request: Request) { + try { + // Parse search parameters + const { searchParams } = new URL(request.url) + const address = searchParams.get("address") + const limit = Number(searchParams.get("limit") || "15") + const pageKey = searchParams.get("pageKey") // Extract pageKey from the request + + // Validate address + if (!address) { + return NextResponse.json({ + error: "Address parameter is required", + }, { status: 400 }); + } + + // Validate address format + if (!isValidAddress(address)) { + return NextResponse.json({ + error: `"${address}" is not a valid Ethereum address`, + details: "Address must start with 0x followed by 40 hexadecimal characters.", + invalidAddress: address + }, { status: 400 }); + } + + // Fetch NFTs from API with pagination + const apiKey = process.env.ALCHEMY_API_KEY + const baseURL = `https://eth-mainnet.g.alchemy.com/nft/v2/${apiKey}/getNFTs` + + // Build URL with pagination parameters + let url = `${baseURL}?owner=${address}&withMetadata=true&pageSize=${limit}` + + // Add pageKey if provided for pagination + if (pageKey) { + url += `&pageKey=${pageKey}` + } + + console.log(`Fetching NFTs with URL: ${url.replace(apiKey || "", "[REDACTED]")}`) + + const response = await fetch(url, { + headers: { Accept: "application/json" } + }) + if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } const data = await response.json() - if (data.status !== "1") { - // If no NFTs are found, return an empty array instead of throwing an error - if (data.message === "No transactions found") { - return NextResponse.json([]) - } - throw new Error(data.message || "Etherscan API returned an error") + if (!data.ownedNfts || data.ownedNfts.length === 0) { + return NextResponse.json({ + nfts: [], + totalCount: 0, + pageKey: null + }, { status: 200 }) } - const nfts = data.result.reduce((acc: NFT[], tx: any) => { - const existingNFT = acc.find((nft) => nft.contractAddress === tx.contractAddress && nft.tokenID === tx.tokenID) - if (!existingNFT) { - acc.push({ - tokenID: tx.tokenID, - tokenName: tx.tokenName, - tokenSymbol: tx.tokenSymbol, - contractAddress: tx.contractAddress, - }) - } - return acc - }, []) - - return NextResponse.json(nfts) + const nfts = data.ownedNfts.map((nft: any) => ({ + tokenID: nft.id.tokenId, + tokenName: nft.metadata?.name || "Unnamed NFT", + tokenSymbol: nft.contract?.symbol || "", + contractAddress: nft.contract?.address, + imageUrl: convertIpfsToGatewayUrl(nft.metadata?.image || nft.metadata?.image_url || "/images/nft-placeholder.png") + })) + + return NextResponse.json({ + nfts, + totalCount: data.totalCount || nfts.length, + pageKey: data.pageKey || null // Ensure we're passing the next pageKey back + }, { status: 200 }) } catch (error) { console.error("Error fetching NFTs:", error) return NextResponse.json( - { error: error instanceof Error ? error.message : "An unknown error occurred" }, - { status: 500 }, + { error: error instanceof Error ? error.message : "Failed to fetch NFTs" }, + { status: 500 } ) } -} - +} \ No newline at end of file diff --git a/app/api/pending/route.ts b/app/api/pending/route.ts new file mode 100644 index 0000000..8d3419e --- /dev/null +++ b/app/api/pending/route.ts @@ -0,0 +1,69 @@ + +import { NextResponse } from "next/server" + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const network = searchParams.get("network") || "mainnet" + + try { + // Use window.location.origin as fallback when process.env.NEXT_PUBLIC_URL is undefined + const baseUrl = typeof window !== 'undefined' + ? window.location.origin + : process.env.NEXT_PUBLIC_URL || '' + + // For Ethereum mainnet, use Etherscan API + if (network === 'mainnet') { + const etherscanResponse = await fetch( + `${baseUrl}/api/etherscan?module=proxy&action=eth_getBlockTransactionCountByNumber&tag=pending` + ); + + if (!etherscanResponse.ok) { + throw new Error(`HTTP error! status: ${etherscanResponse.status}`) + } + + const etherscanData = await etherscanResponse.json(); + + if (etherscanData.error) { + throw new Error(etherscanData.error || "Etherscan API returned an error") + } + + const pendingTxCount = parseInt(etherscanData.result, 16); + + return NextResponse.json({ pendingTransactions: pendingTxCount, network }); + } + // For other networks, use Infura API + else { + const response = await fetch(`${baseUrl}/api/infura`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + method: "eth_getBlockTransactionCountByNumber", + params: ["pending"], + network + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + + if (data.error) { + throw new Error(data.error.message || "Infura API returned an error") + } + + const pendingTxCount = parseInt(data.result, 16) + + return NextResponse.json({ pendingTransactions: pendingTxCount, network }) + } + } catch (error) { + console.error("Error fetching pending transactions:", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "An unknown error occurred" }, + { status: 500 } + ) + } +} diff --git a/app/api/portfolio/route.ts b/app/api/portfolio/route.ts new file mode 100644 index 0000000..85e2e95 --- /dev/null +++ b/app/api/portfolio/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server"; +import * as moralisApi from "@/lib/api/moralisApi"; +import * as alchemyApi from "@/lib/api/alchemyApi"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const address = searchParams.get("address"); + const provider = searchParams.get("provider") || "moralis"; // Default to Moralis + + if (!address) { + return NextResponse.json( + { error: "Address is required" }, + { status: 400 } + ); + } + + // Validate Ethereum address format + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) { + return NextResponse.json( + { error: "Invalid Ethereum address format" }, + { status: 400 } + ); + } + + interface Token { + contractAddress?: string; + chain?: string; + balanceFormatted?: string; + usdPrice?: number; + usdValue?: number; + [key: string]: any; // Allow for other properties + } + + let tokens: Token[] = []; + let totalValue = 0; + let apiError = null; + + // Fetch tokens based on provider + try { + if (provider === "moralis") { + tokens = await moralisApi.getWalletTokens(address); + + // Check if we got tokens but Moralis API key isn't configured + if (tokens.length === 0 && !process.env.MORALIS_API_KEY) { + apiError = "Moralis API key is not configured. Please set MORALIS_API_KEY in your environment variables."; + } + } else if (provider === "alchemy") { + tokens = await alchemyApi.getWalletTokens(address); + + // Enrich Alchemy tokens with price data + const nativePrices = await alchemyApi.getNativePrices(); + + tokens = tokens.map(token => { + if (token.contractAddress === 'native') { + const usdPrice = token.chain ? nativePrices[token.chain] || 0 : 0; + const balance = parseFloat(token.balanceFormatted || '0'); + return { + ...token, + usdPrice, + usdValue: balance * usdPrice + }; + } + return token; + }); + + // For ERC20 tokens, we would need to fetch prices from another source + // This is a simplified version + } else if (provider === "combined") { + // Fetch from both providers and merge results + const moralisTokens = await moralisApi.getWalletTokens(address); + const alchemyTokens = await alchemyApi.getWalletTokens(address); + + // In a real implementation, you would deduplicate tokens here + tokens = [...moralisTokens, ...alchemyTokens]; + } else { + return NextResponse.json( + { error: "Invalid provider. Use 'moralis', 'alchemy', or 'combined'" }, + { status: 400 } + ); + } + } catch (providerError) { + console.error(`Error with ${provider} provider:`, providerError); + apiError = `The ${provider} provider encountered an error: ${ + providerError instanceof Error ? providerError.message : String(providerError) + }`; + } + + // Calculate total USD value + totalValue = tokens.reduce((acc, token) => acc + (token.usdValue || 0), 0); + + return NextResponse.json({ + address, + tokens, + totalValue, + totalBalance: tokens.length, + provider, + apiError // Include any API-specific errors in the response + }); + } catch (error) { + console.error("Error fetching portfolio:", error); + + return NextResponse.json( + { error: "Failed to fetch portfolio data", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/proxy/route.ts b/app/api/proxy/route.ts new file mode 100644 index 0000000..32a43b3 --- /dev/null +++ b/app/api/proxy/route.ts @@ -0,0 +1,49 @@ +// This file creates a proxy endpoint to bypass CORS issues during development + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + + if (!url) { + return new Response(JSON.stringify({ error: 'URL parameter is required' }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'CryptoPath/1.0', + }, + }); + + if (!response.ok) { + return new Response(JSON.stringify({ error: `API responded with status: ${response.status}` }), { + status: response.status, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + const data = await response.json(); + + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'max-age=60, s-maxage=60', + }, + }); + } catch (error) { + console.error('Proxy error:', error); + return new Response(JSON.stringify({ error: 'Failed to fetch from remote API' }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + }); + } +} diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts index f827914..b836ad3 100644 --- a/app/api/subscribe/route.ts +++ b/app/api/subscribe/route.ts @@ -13,6 +13,16 @@ export async function POST(request: Request) { ); } + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + const errorMessage = language === 'en' ? 'Invalid email format' : 'Định dạng email không hợp lệ'; + return NextResponse.json( + { success: false, message: errorMessage }, + { status: 400 } + ); + } + // Nodemailer configuration const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST || 'smtp.example.com', @@ -106,10 +116,13 @@ export async function POST(request: Request) { await transporter.sendMail(mailOptions); + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 500)); + // Success response based on language const successMessage = language === 'en' ? 'Subscription successful!' : 'Đăng ký thành công!'; return NextResponse.json( - { success: true, message: successMessage }, + { success: true, message: successMessage, data: { email, timestamp: new Date().toISOString() } }, { status: 200 } ); } catch (error) { diff --git a/app/api/token-transactions/route.ts b/app/api/token-transactions/route.ts new file mode 100644 index 0000000..4a375fd --- /dev/null +++ b/app/api/token-transactions/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { TOKEN_CONTRACTS } from "@/services/cryptoService"; + +const ETHERSCAN_API_URL = "https://api.etherscan.io/api"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const coinId = searchParams.get("coinId"); + const page = searchParams.get("page") || "1"; + const offset = searchParams.get("offset") || "50"; + + if (!coinId) { + return NextResponse.json({ error: "Coin ID is required" }, { status: 400 }); + } + + try { + const contractAddress = TOKEN_CONTRACTS[coinId]; + if (!contractAddress) { + return NextResponse.json({ error: "Unsupported token" }, { status: 400 }); + } + + let url: string; + if (coinId === 'ethereum') { + // For ETH, fetch normal transactions + url = `${ETHERSCAN_API_URL}?module=account&action=txlist&address=0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D&startblock=0&endblock=99999999&page=${page}&offset=${offset}&sort=desc&apikey=${process.env.ETHERSCAN_API_KEY}`; + } else { + // For other tokens, fetch their specific ERC20 transactions + url = `${ETHERSCAN_API_URL}?module=account&action=tokentx&contractaddress=${contractAddress}&page=${page}&offset=${offset}&sort=desc&apikey=${process.env.ETHERSCAN_API_KEY}`; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + if (data.status !== "1") { + if (data.message === "No transactions found") { + return NextResponse.json([]); + } + throw new Error(data.message || "Etherscan API returned an error"); + } + + const transactions = data.result.map((tx: any) => ({ + hash: tx.hash, + from: tx.from, + to: tx.to, + value: coinId === 'ethereum' + ? `${(Number(tx.value) / 1e18).toFixed(6)} ETH` + : `${(Number(tx.value) / Math.pow(10, Number(tx.tokenDecimal))).toFixed(6)} ${tx.tokenSymbol || coinId.toUpperCase()}`, + timestamp: Number(tx.timeStamp) * 1000, + gasPrice: tx.gasPrice, + gasUsed: tx.gasUsed, + method: tx.input && tx.input !== '0x' ? 'Contract Interaction' : 'Transfer' + })); + + return NextResponse.json(transactions); + } catch (error) { + console.error("Error fetching token transactions:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "An unknown error occurred" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index 79c2f79..204203b 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -1,46 +1,274 @@ import { NextResponse } from "next/server" -const ETHERSCAN_API_URL = "https://api.etherscan.io/api" +// Add timeout utility - only for non-Infura requests +const fetchWithTimeout = async (url: string, options: RequestInit = {}, timeout = 15000, isInfura = false) => { + if (isInfura) { + // For Infura, don't use a timeout + return fetch(url, options); + } + + const controller = new AbortController(); + const { signal } = controller; + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + controller.abort(); + reject(new Error("Request timeout")); + }, timeout); + }); + + return Promise.race([ + fetch(url, { ...options, signal }), + timeoutPromise + ]); +}; + +// Enhanced address validation +const isValidAddress = (address: string): boolean => { + // Basic Ethereum address format check + return /^0x[a-fA-F0-9]{40}$/.test(address); +}; export async function GET(request: Request) { const { searchParams } = new URL(request.url) const address = searchParams.get("address") - const page = searchParams.get("page") || "1" - const offset = searchParams.get("offset") || "50" // Increased to 50 transactions + const network = searchParams.get("network") || "mainnet" + const provider = searchParams.get("provider") || "etherscan" + const page = parseInt(searchParams.get("page") || "1") + const offset = parseInt(searchParams.get("offset") || "20") + // Validate address presence if (!address) { - return NextResponse.json({ error: "Address is required" }, { status: 400 }) + return NextResponse.json({ + error: "Address is required", + details: "Please provide an Ethereum address parameter." + }, { status: 400 }) + } + + // Validate address format + if (!isValidAddress(address)) { + return NextResponse.json({ + error: `"${address}" is not a valid Ethereum address`, + details: "Address must start with 0x followed by 40 hexadecimal characters.", + invalidAddress: address, + suggestion: "Check for typos and ensure you have copied the complete address." + }, { status: 400 }); } try { - const response = await fetch( - `${ETHERSCAN_API_URL}?module=account&action=txlist&address=${address}&startblock=0&endblock=99999999&page=${page}&offset=${offset}&sort=desc&apikey=${process.env.ETHERSCAN_API_KEY}`, - ) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) + // Get the base URL dynamically + const baseUrl = typeof window !== 'undefined' + ? window.location.origin + : process.env.NEXT_PUBLIC_URL || '' + + // Use Etherscan API for Ethereum mainnet + if (provider === 'etherscan' && network === 'mainnet') { + const etherscanResponse = await fetchWithTimeout( + `${baseUrl}/api/etherscan?module=account&action=txlist&address=${address}&page=${page}&offset=${offset}&sort=desc`, + {}, + 20000 // 20s timeout + ) + + if (!etherscanResponse.ok) { + throw new Error(`Etherscan API responded with status: ${etherscanResponse.status}`) + } + + const etherscanData = await etherscanResponse.json() + + if (etherscanData.status !== "1") { + // If no transactions are found, return an empty array + if (etherscanData.message === "No transactions found") { + return NextResponse.json([]) + } + throw new Error(etherscanData.message || "Etherscan API returned an error") + } + + // Transform Etherscan data to match our expected format + const transactions = etherscanData.result.map((tx: any) => { + const valueInEth = parseInt(tx.value) / 1e18 + + return { + id: tx.hash, + from: tx.from, + to: tx.to || "Contract Creation", + value: `${valueInEth.toFixed(4)} ETH`, + timestamp: new Date(parseInt(tx.timeStamp) * 1000).toISOString(), + network, + gas: parseInt(tx.gas), + gasPrice: parseInt(tx.gasPrice) / 1e9, // in gwei + blockNumber: parseInt(tx.blockNumber), + nonce: parseInt(tx.nonce) + } + }) + + return NextResponse.json(transactions) } - - const data = await response.json() - - if (data.status !== "1") { - throw new Error(data.message || "Etherscan API returned an error") + // Use Infura for other networks (Optimism, Arbitrum) + else if (provider === 'infura') { + console.log(`Infura transaction search started for ${address} on ${network}`); + // First get the latest block number - no timeout for Infura + const blockNumberResponse = await fetchWithTimeout( + `${baseUrl}/api/infura`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + method: "eth_blockNumber", + params: [], + network + }), + }, + 0, // No timeout for Infura + true // Mark as Infura request + ); + + if (!blockNumberResponse.ok) { + throw new Error(`Infura API responded with status: ${blockNumberResponse.status}`); + } + + const blockNumberData = await blockNumberResponse.json(); + + if (blockNumberData.error) { + throw new Error(blockNumberData.error.message || "Failed to fetch block number"); + } + + const latestBlock = parseInt(blockNumberData.result, 16); + console.log(`Latest block number: ${latestBlock}`); + + // Use a more direct approach for getting transactions + // Just scan the most recent N blocks for transactions involving the address + const blockRange = 50; // Reduced block range to prevent timeouts + const transactions = []; + const processedTxHashes = new Set(); + + // Determine currency symbol based on network + const currencySymbol = network === "optimism" ? "ETH" : + network === "arbitrum" ? "ETH" : "ETH"; + + console.log(`Scanning ${blockRange} recent blocks for transactions...`); + + // Start from the latest block and work backward + for (let blockNum = latestBlock; blockNum > latestBlock - blockRange; blockNum--) { + try { + console.log(`Processing block ${blockNum}...`); + // Get the block with transactions - no timeout for Infura + const blockResponse = await fetchWithTimeout( + `${baseUrl}/api/infura`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + method: "eth_getBlockByNumber", + params: [`0x${blockNum.toString(16)}`, true], + network + }), + }, + 0, // No timeout for Infura + true // Mark as Infura request + ); + + // Check for valid response + if (!blockResponse.ok) { + console.warn(`Skipping block ${blockNum} due to non-OK response`); + continue; // Skip if we can't get this block + } + + const blockData = await blockResponse.json(); + + // Skip if no data or no transactions + if (blockData.error || !blockData.result || !blockData.result.transactions) { + console.warn(`Skipping block ${blockNum} due to missing data`, blockData.error || 'No transactions'); + continue; + } + + // Filter transactions for the address + const addrLower = address.toLowerCase(); + const relevantTxs = blockData.result.transactions.filter( + (tx: any) => + tx.from && tx.from.toLowerCase() === addrLower || + tx.to && tx.to.toLowerCase() === addrLower + ); + + console.log(`Found ${relevantTxs.length} relevant transactions in block ${blockNum}`); + + // For each relevant transaction, get more details + for (const tx of relevantTxs) { + // Skip if we already processed this transaction + if (processedTxHashes.has(tx.hash)) { + continue; + } + + const valueInWei = tx.value ? parseInt(tx.value, 16) : 0; + + transactions.push({ + id: tx.hash, + from: tx.from, + to: tx.to || "Contract Creation", + value: `${(valueInWei / 1e18).toFixed(4)} ${currencySymbol}`, + timestamp: new Date(parseInt(blockData.result.timestamp, 16) * 1000).toISOString(), + network, + gas: parseInt(tx.gas, 16), + gasPrice: tx.gasPrice ? parseInt(tx.gasPrice, 16) / 1e9 : 0, // in gwei + blockNumber: parseInt(tx.blockNumber, 16), + nonce: parseInt(tx.nonce, 16) + }); + + processedTxHashes.add(tx.hash); + } + + // Stop if we've collected enough transactions for this page + if (transactions.length >= offset) { + console.log(`Collected enough transactions (${transactions.length}), stopping block scan`); + break; + } + } catch (blockError) { + console.warn(`Error fetching block ${blockNum}:`, blockError); + continue; // Skip problematic blocks + } + } + + console.log(`Transaction scan complete, found ${transactions.length} transactions`); + + // Sort by block number (timestamp) descending + transactions.sort((a, b) => b.blockNumber - a.blockNumber); + + // Apply pagination + const paginatedTransactions = transactions.slice( + Math.min((page - 1) * offset, transactions.length), + Math.min(page * offset, transactions.length) + ); + + return NextResponse.json(paginatedTransactions.length > 0 ? paginatedTransactions : []); + } + else { + return NextResponse.json({ error: "Unsupported provider or network combination" }, { status: 400 }); } - - const transactions = data.result.map((tx: any) => ({ - id: tx.hash, - from: tx.from, - to: tx.to, - value: `${(Number.parseFloat(tx.value) / 1e18).toFixed(4)} ETH`, - timestamp: new Date(Number.parseInt(tx.timeStamp) * 1000).toISOString(), - })) - - return NextResponse.json(transactions) } catch (error) { - console.error("Error fetching transactions:", error) + console.error("Error fetching transactions:", error); + let errorMessage = "Failed to fetch transactions"; + let statusCode = 500; + + if (error instanceof Error) { + if (error.name === "AbortError") { + errorMessage = "Request was aborted while fetching transactions. Please try using Infura provider which has no timeout."; + statusCode = 499; // Client Closed Request + } else if (error.message === "Request timeout") { + errorMessage = `Request timed out while fetching transactions. ${ + provider === 'infura' ? 'Please try again.' : 'Try switching to Infura provider.' + }`; + statusCode = 504; // Gateway Timeout + } else { + errorMessage = error.message; + } + } + return NextResponse.json( - { error: error instanceof Error ? error.message : "An unknown error occurred" }, - { status: 500 }, - ) + { error: errorMessage }, + { status: statusCode }, + ); } -} \ No newline at end of file +} diff --git a/app/api/wallet/route.ts b/app/api/wallet/route.ts index d63cf96..26ade44 100644 --- a/app/api/wallet/route.ts +++ b/app/api/wallet/route.ts @@ -1,37 +1,228 @@ import { NextResponse } from "next/server" -const ETHERSCAN_API_URL = "https://api.etherscan.io/api" +// Add a timeout function for fetch requests - skip timeout for Infura +const fetchWithTimeout = async (url: string, options: RequestInit = {}, timeout = 15000, isInfura = false) => { + if (isInfura) { + // For Infura, don't use a timeout + return fetch(url, options); + } + + const controller = new AbortController(); + const { signal } = controller; + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + controller.abort(); + reject(new Error("Request timeout")); + }, timeout); + }); + + return Promise.race([ + fetch(url, { ...options, signal }), + timeoutPromise + ]); +}; + +// Enhanced address validation +const isValidAddress = (address: string): boolean => { + // Basic Ethereum address format check + return /^0x[a-fA-F0-9]{40}$/.test(address); +}; export async function GET(request: Request) { const { searchParams } = new URL(request.url) const address = searchParams.get("address") + const network = searchParams.get("network") || "mainnet" + const provider = searchParams.get("provider") || "etherscan" + // Enhanced input validation if (!address) { - return NextResponse.json({ error: "Address is required" }, { status: 400 }) + return NextResponse.json({ + error: "Address is required", + details: "Please provide an Ethereum address parameter." + }, { status: 400 }) + } + + // Validate address format - return helpful error details + if (!isValidAddress(address)) { + return NextResponse.json({ + error: `"${address}" is not a valid Ethereum address`, + details: "Address must start with 0x followed by 40 hexadecimal characters.", + invalidAddress: address, + suggestion: "Check for typos and ensure you have copied the complete address." + }, { status: 400 }); } try { - const balanceResponse = await fetch( - `${ETHERSCAN_API_URL}?module=account&action=balance&address=${address}&tag=latest&apikey=${process.env.ETHERSCAN_API_KEY}`, - ) - const balanceData = await balanceResponse.json() - - const txCountResponse = await fetch( - `${ETHERSCAN_API_URL}?module=proxy&action=eth_getTransactionCount&address=${address}&tag=latest&apikey=${process.env.ETHERSCAN_API_KEY}`, - ) - const txCountData = await txCountResponse.json() - - const balance = Number.parseFloat(balanceData.result) / 1e18 // Convert wei to ETH - const transactionCount = Number.parseInt(txCountData.result, 16) // Convert hex to decimal - - return NextResponse.json({ - address, - balance: `${balance.toFixed(4)} ETH`, - transactionCount, - }) + // Get the base URL dynamically + const baseUrl = typeof window !== 'undefined' + ? window.location.origin + : process.env.NEXT_PUBLIC_URL || '' + + // Use Etherscan for Ethereum Mainnet + if (provider === 'etherscan' && network === 'mainnet') { + // Fetch balance using Etherscan with timeout + try { + const etherscanBalanceResponse = await fetchWithTimeout( + `${baseUrl}/api/etherscan?module=account&action=balance&address=${address}&tag=latest`, + {}, + 15000 // 15s timeout (increased from 10s) + ); + + if (!etherscanBalanceResponse.ok) { + throw new Error(`Etherscan API responded with status: ${etherscanBalanceResponse.status}`); + } + + const balanceData = await etherscanBalanceResponse.json(); + + if (balanceData.status !== "1") { + // Special handling for "No transactions found" which is not an actual error + if (balanceData.message === "No transactions found") { + // Return empty balance instead of error + const balance = 0; + return NextResponse.json({ + address, + balance: `0.0000 ETH`, + transactionCount: 0, + network + }); + } + throw new Error(balanceData.message || "Failed to fetch balance from Etherscan"); + } + + // Fetch transaction count using Etherscan + const etherscanTxCountResponse = await fetchWithTimeout( + `${baseUrl}/api/etherscan?module=proxy&action=eth_getTransactionCount&address=${address}&tag=latest`, + {}, + 15000 // 15s timeout (increased from 10s) + ); + + if (!etherscanTxCountResponse.ok) { + throw new Error(`Etherscan API responded with status: ${etherscanTxCountResponse.status}`); + } + + const txCountData = await etherscanTxCountResponse.json(); + + if (txCountData.status === "0") { + throw new Error(txCountData.message || "Failed to fetch transaction count from Etherscan"); + } + + const balance = Number.parseInt(balanceData.result, 10) / 1e18; // Convert wei to ETH + const transactionCount = Number.parseInt(txCountData.result, 16); // Convert hex to decimal + + return NextResponse.json({ + address, + balance: `${balance.toFixed(4)} ETH`, + transactionCount, + network + }); + } catch (error) { + console.error("Etherscan API error:", error); + throw error; // Let the outer catch block handle it + } + } + // Use Infura for other networks + else if (provider === 'infura') { + try { + console.log(`Fetching wallet data via Infura for ${address} on ${network}`); + + // Fetch balance using Infura without timeout + const infuraBalanceResponse = await fetchWithTimeout(`${baseUrl}/api/infura`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + method: "eth_getBalance", + params: [address, "latest"], + network + }), + }, 0, true); // No timeout for Infura, mark as Infura request + + if (!infuraBalanceResponse.ok) { + throw new Error(`Infura API responded with status: ${infuraBalanceResponse.status}`); + } + + const balanceData = await infuraBalanceResponse.json(); + + if (balanceData.error) { + throw new Error(balanceData.error.message || "Failed to fetch balance from Infura"); + } + + // Fetch transaction count using Infura without timeout + const infuraTxCountResponse = await fetchWithTimeout(`${baseUrl}/api/infura`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + method: "eth_getTransactionCount", + params: [address, "latest"], + network + }), + }, 0, true); // No timeout for Infura, mark as Infura request + + if (!infuraTxCountResponse.ok) { + throw new Error(`Infura API responded with status: ${infuraTxCountResponse.status}`); + } + + const txCountData = await infuraTxCountResponse.json(); + + if (txCountData.error) { + throw new Error(txCountData.error.message || "Failed to fetch transaction count from Infura"); + } + + // Determine currency symbol based on network + const currencySymbol = network === "optimism" || network === "arbitrum" ? "ETH" : "ETH"; + + // Handle case where balance is zero (return 0 instead of failing) + const hexBalance = balanceData.result || "0x0"; + const balance = Number.parseInt(hexBalance, 16) / 1e18; // Convert wei to ETH + const transactionCount = Number.parseInt(txCountData.result || "0x0", 16); // Convert hex to decimal + + return NextResponse.json({ + address, + balance: `${balance.toFixed(4)} ${currencySymbol}`, + transactionCount, + network + }); + } catch (error) { + console.error("Infura API error:", error); + throw error; // Let the outer catch block handle it + } + } + else { + return NextResponse.json({ error: "Unsupported provider or network combination" }, { status: 400 }); + } } catch (error) { - console.error("Error fetching wallet data:", error) - return NextResponse.json({ error: "Failed to fetch wallet data" }, { status: 500 }) + console.error("Error fetching wallet data:", error); + + // More specific error messages based on the error type + let errorMessage = "Failed to fetch wallet data"; + let statusCode = 500; + + if (error instanceof Error) { + if (error.name === "AbortError") { + errorMessage = provider === 'infura' + ? "Unexpected abort of Infura request. Please try again." + : "Request was aborted. Try switching to Infura provider which has no timeout."; + statusCode = 499; // Client Closed Request + } else if (error.message === "Request timeout") { + errorMessage = provider === 'infura' + ? "Infura request timed out unexpectedly." + : "Request timed out while fetching wallet data. Try switching to Infura."; + statusCode = 504; // Gateway Timeout + } else if (error.message.includes("Failed to fetch")) { + errorMessage = "Network error while fetching wallet data"; + statusCode = 503; // Service Unavailable + } else if (error.message.includes("not found") || error.message.includes("No record")) { + errorMessage = `Address not found on ${network} network`; + statusCode = 404; // Not Found + } else { + errorMessage = error.message; + } + } + + return NextResponse.json({ error: errorMessage }, { status: statusCode }); } } - diff --git a/app/globals.css b/app/globals.css index 9caeb36..6fc5a09 100644 --- a/app/globals.css +++ b/app/globals.css @@ -11,9 +11,39 @@ * - cp-*--* for component modifiers */ -/* ====================================== - * Base Styles - * ====================================== */ +:root { + --brand-color: #F5B056; + --background: #000000; + --foreground: #ffffff; + --border: #333333; +} + +/* Custom Scrollbar - Binance style */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: #1a1a1a; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #444; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #666; +} + +body { + color: var(--foreground); + background: var(--background); + font-feature-settings: "rlig" 1, "calt" 1; +} + @layer base { body { @apply bg-black text-white font-sans overflow-x-hidden; @@ -59,7 +89,7 @@ * Button System * ====================================== */ .cp-button { - @apply px-6 py-3 rounded-lg font-semibold transition cursor-pointer; + @apply px-6 py-3 rounded-[5px] font-semibold transition cursor-pointer; } .cp-button--primary { @@ -71,16 +101,16 @@ } .cp-button--rounded { - @apply rounded-full; + @apply rounded-[5px]; } /* Legacy button styles - consider migrating to cp-button system */ .btn { - @apply py-3 px-6 border border-white rounded-md font-semibold cursor-pointer transition-all duration-200; + @apply py-3 px-6 border border-white rounded-[5px] font-semibold cursor-pointer transition-all duration-200; } .btn:hover { - @apply rounded-[0.9rem]; + @apply rounded-[5px]; } .btn:active { @@ -99,7 +129,7 @@ * Card System * ====================================== */ .cp-card { - @apply rounded-lg shadow-md overflow-hidden; + @apply rounded-[5px] shadow-md overflow-hidden; } .cp-card--dark { @@ -130,7 +160,7 @@ } .cp-input { - @apply px-4 py-2 rounded-md focus:outline-none; + @apply px-4 py-2 rounded-[5px] focus:outline-none; } .cp-input--dark { @@ -141,12 +171,12 @@ * Media Components * ====================================== */ .cp-video-container { - @apply bg-[#2d2d2d] rounded-md overflow-hidden relative w-full max-w-[800px] mx-auto; + @apply bg-[#2d2d2d] rounded-[5px] overflow-hidden relative w-full max-w-[800px] mx-auto; } /* Legacy video container - consider migrating to cp-video-container */ .video-container { - @apply bg-[#2d2d2d] rounded-md overflow-hidden relative w-full max-w-[800px] mx-auto; + @apply bg-[#2d2d2d] rounded-[5px] overflow-hidden relative w-full max-w-[800px] mx-auto; } @media screen and (max-width: 768px) { @@ -195,12 +225,12 @@ * Partner Components * ====================================== */ .cp-trusted-partner { - @apply bg-white p-4 rounded-md shadow-md text-center; + @apply bg-white p-4 rounded-[5px] shadow-md text-center; } /* Legacy trusted logo styles - consider migrating to cp-trusted-partner */ .trusted-logo { - @apply bg-white p-4 rounded-md shadow-md text-center; + @apply bg-white p-4 rounded-[5px] shadow-md text-center; } /* ====================================== @@ -220,17 +250,14 @@ /* Legacy navigation styles - consider migrating to cp-nav-link */ nav a { - @apply relative p-4 m-4 transition-all duration-200; + @apply p-4 transition-all duration-200; animation: appear 2s forwards; } - - nav a::after { - @apply content-[""] h-[3px] w-0 bg-white absolute left-0 bottom-0 transition-all duration-500; - } - - nav a:hover::after { - @apply w-full; + nav form{ + @apply transition-all duration-200; + animation: appear 2s forwards; } + /* ====================================== * Mobile Menu @@ -384,6 +411,50 @@ ); } +/* Fonts */ +.font-exo2 { + font-family: var(--font-exo2), sans-serif; +} + +.font-quantico { + font-family: var(--font-quantico), monospace; +} + +/* Transitions */ +.transition-gpu { + transition-property: transform, opacity; + will-change: transform, opacity; +} + +/* Custom animation for price change */ +@keyframes price-up { + 0% { background-color: rgba(34, 197, 94, 0.3); } + 100% { background-color: transparent; } +} + +@keyframes price-down { + 0% { background-color: rgba(239, 68, 68, 0.3); } + 100% { background-color: transparent; } +} + +.price-up { + animation: price-up 1s ease-out; +} + +.price-down { + animation: price-down 1s ease-out; +} + +/* Apply backdrop filter for frosted glass effect */ +.backdrop-blur-md { + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.backdrop-blur-sm { + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} /* ====================================== * Animation Keyframes diff --git a/app/layout.tsx b/app/layout.tsx index da6081e..76bd95d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,27 +1,68 @@ +/** + * Main layout configuration for the CryptoPath application + * This file defines the root structure and global providers used across the app + */ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +// Core layout components import Header from "@/components/Header"; import Footer from "@/components/Footer"; import ParticlesBackground from "@/components/ParticlesBackground"; import { SplashScreen } from '@/components/SplashScreen'; -import QueryProvider from "./QueryProvider"; // ✅ Import Client Component +// State management and context providers +import QueryProvider from "./QueryProvider"; // Data fetching provider import "./globals.css"; -import { Toaster } from 'react-hot-toast'; -import { WalletProvider } from '@/components/Faucet/walletcontext'; // Thêm WalletProvider +import { Toaster } from 'react-hot-toast'; // Toast notification system +import { WalletProvider } from '@/components/Faucet/walletcontext'; // Blockchain wallet context +import { AuthProvider } from '@/lib/context/AuthContext'; // Authentication context +import { DebugBadge } from "@/components/ui/debug-badge"; +import { SettingsProvider } from "@/components/context/SettingsContext"; // Add this import +import SearchOnTop from "@/components/SearchOnTop"; +import { Inter, Exo_2, Quantico } from 'next/font/google'; +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', +}); + +const exo2 = Exo_2({ + subsets: ['latin'], + variable: '--font-exo2', +}); + +const quantico = Quantico({ + subsets: ['latin'], + weight: ['400', '700'], + variable: '--font-quantico', +}); + +export const dynamic = 'force-dynamic'; + +/** + * Geist Sans font configuration + * A modern, minimalist sans-serif typeface for primary text content + */ const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], + variable: "--font-geist-sans", // CSS variable name for font-family access + subsets: ["latin"], // Character subset for optimization }); +/** + * Geist Mono font configuration + * A monospace variant for code blocks and technical content + */ const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], + variable: "--font-geist-mono", // CSS variable name for font-family access + subsets: ["latin"], // Character subset for optimization }); +/** + * Metadata configuration for the CryptoPath application. + * [existing comment preserved] + */ export const metadata: Metadata = { - title: "CryptoPath", - description: "Create by members of group 3 - Navigate the world of blockchain with CryptoPath", + title: "CryptoPath - Blockchain Explorer", + description: "A comprehensive tool for exploring blockchain data", icons: { icon: "/favicon.ico", }, @@ -45,26 +86,46 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +/** + * Root layout component that wraps the entire application + * Establishes the provider hierarchy for global state and context + * + * @param children - The page content to render within the layout + */ +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - {/* Bao bọc bằng WalletProvider */} - {/* ✅ Bọc bên trong Client Component */} - -
- {children} - -
- - + + + {/* Add meta tags for better mobile experience */} + + + +
+ {/* AuthProvider - Manages user authentication state */} + + {/* Add SettingsProvider here */} + + {/* WalletProvider - Manages blockchain wallet connections and state */} + + {/* QueryProvider - Handles data fetching and caching */} + + {/* Application UI components */} + {/* Initial loading screen */} + +
{/* Global navigation */} + {children} {/* Page-specific content */} + {/* Toast notification container */} +
{/* Global footer */} + + {/* Debug Badge - Only shows in development when needed */} + + + + + +
+ ); -} \ No newline at end of file +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 9970630..0d8bfee 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,249 +1,309 @@ -'use client'; -import Link from 'next/link'; -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import ParticlesBackground from '@/components/ParticlesBackground'; -import { Web3OnboardProvider, init, useConnectWallet } from '@web3-onboard/react'; -import injectedModule from '@web3-onboard/injected-wallets'; -import walletConnectModule from '@web3-onboard/walletconnect'; -import coinbaseModule from '@web3-onboard/coinbase'; -import infinityWalletModule from '@web3-onboard/infinity-wallet' -import safeModule from '@web3-onboard/gnosis' -import trezorModule from '@web3-onboard/trezor' -import magicModule from '@web3-onboard/magic' -import dcentModule from '@web3-onboard/dcent'; - -const dcent = dcentModule(); -import sequenceModule from '@web3-onboard/sequence' -import tahoModule from '@web3-onboard/taho' -import trustModule from '@web3-onboard/trust' -import okxModule from '@web3-onboard/okx' -import frontierModule from '@web3-onboard/frontier'; - -const INFURA_KEY = '7d389678fba04ceb9510b2be4fff5129'; // Replace with your Infura key - -// Initialize WalletConnect with projectId -const walletConnect = walletConnectModule({ - projectId: 'b773e42585868b9b143bb0f1664670f1', // Replace with your WalletConnect project ID - optionalChains: [1, 137] // Optional: specify chains you want to support -}); +"use client"; +import Link from "next/link"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import ParticlesBackground from "@/components/ParticlesBackground"; +import { toast } from "sonner"; +import { supabase } from "@/src/integrations/supabase/client"; +import { Web3OnboardProvider, init, useConnectWallet } from "@web3-onboard/react"; +import injectedModule from "@web3-onboard/injected-wallets"; +import walletConnectModule from "@web3-onboard/walletconnect"; +import coinbaseModule from "@web3-onboard/coinbase"; +import infinityWalletModule from "@web3-onboard/infinity-wallet"; +import safeModule from "@web3-onboard/gnosis"; +import trezorModule from "@web3-onboard/trezor"; +import magicModule from "@web3-onboard/magic"; +import dcentModule from "@web3-onboard/dcent"; +import sequenceModule from "@web3-onboard/sequence"; +import tahoModule from "@web3-onboard/taho"; +import trustModule from "@web3-onboard/trust"; +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 { 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 -const injected = injectedModule(); -const coinbase = coinbaseModule(); -const infinityWallet = infinityWalletModule() -const safe = safeModule() -const sequence = sequenceModule() -const taho = tahoModule() // Previously named Tally Ho wallet -const trust = trustModule() -const okx = okxModule() -const frontier = frontierModule() -const trezorOptions = { - email: 'test@test.com', - appUrl: 'https://www.blocknative.com' -} +// Web3-Onboard configuration +const INFURA_KEY = "7d389678fba04ceb9510b2be4fff5129"; +const walletConnect = walletConnectModule({ + projectId: "b773e42585868b9b143bb0f1664670f1", + optionalChains: [1, 137], +}); -const trezor = trezorModule(trezorOptions) - -const magic = magicModule({ - apiKey: 'pk_live_E9B0C0916678868E' -}) - -const wallets = [infinityWallet, - sequence, - injected, - trust, - okx, - frontier, - taho, - coinbase, - dcent, +const wallets = [ + 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 = [ - { - id: '0x1', - token: 'ETH', - label: 'Ethereum Mainnet', - rpcUrl: `https://mainnet.infura.io/v3/${INFURA_KEY}`, - }, - { - id: 11155111, - token: 'ETH', - label: 'Sepolia', - rpcUrl: 'https://rpc.sepolia.org/' - }, - { - id: '0x13881', - token: 'MATIC', - label: 'Polygon - Mumbai', - rpcUrl: 'https://matic-mumbai.chainstacklabs.com', - }, - { - id: '0x38', - token: 'BNB', - label: 'Binance', - rpcUrl: 'https://bsc-dataseed.binance.org/' - }, - { - id: '0xA', - token: 'OETH', - label: 'OP Mainnet', - rpcUrl: 'https://mainnet.optimism.io' - }, - { - id: '0xA4B1', - token: 'ARB-ETH', - label: 'Arbitrum', - rpcUrl: 'https://rpc.ankr.com/arbitrum' - }, - { - id: '0xa4ec', - token: 'ETH', - label: 'Celo', - rpcUrl: 'https://1rpc.io/celo' - }, - { - id: 666666666, - token: 'DEGEN', - label: 'Degen', - rpcUrl: 'https://rpc.degen.tips' - }, - { - id: 2192, - token: 'SNAX', - label: 'SNAX Chain', - rpcUrl: 'https://mainnet.snaxchain.io' - } + { id: "0x1", token: "ETH", label: "Ethereum Mainnet", rpcUrl: `https://mainnet.infura.io/v3/${INFURA_KEY}` }, + { id: "11155111", token: "ETH", label: "Sepolia", rpcUrl: "https://rpc.sepolia.org/" }, + { id: "0x13881", token: "MATIC", label: "Polygon - Mumbai", rpcUrl: "https://matic-mumbai.chainstacklabs.com" }, + { id: "0x38", token: "BNB", label: "Binance", rpcUrl: "https://bsc-dataseed.binance.org/" }, + { id: "0xA", token: "OETH", label: "OP Mainnet", rpcUrl: "https://mainnet.optimism.io" }, + { id: "0xA4B1", token: "ARB-ETH", label: "Arbitrum", rpcUrl: "https://rpc.ankr.com/arbitrum" }, + { id: "0xa4ec", token: "ETH", label: "Celo", rpcUrl: "https://1rpc.io/celo" }, + { id: "666666666", token: "DEGEN", label: "Degen", rpcUrl: "https://rpc.degen.tips" }, + { id: "2192", token: "SNAX", label: "SNAX Chain", rpcUrl: "https://mainnet.snaxchain.io" }, ]; const appMetadata = { - name: 'CryptoPath', - //icon: '', // Replace with your actual icon - description: 'Login to CryptoPath with your wallet', + name: "CryptoPath", + description: "Login to CryptoPath with your wallet", recommendedInjectedWallets: [ - { name: 'MetaMask', url: 'https://metamask.io' }, - { name: 'Coinbase', url: 'https://wallet.coinbase.com/' }, + { name: "MetaMask", url: "https://metamask.io" }, + { name: "Coinbase", url: "https://wallet.coinbase.com/" }, ], }; -const web3Onboard = init({ - wallets, - chains, - 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); - // Form state - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [emailError, setEmailError] = useState(''); - const [passwordError, setPasswordError] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [isLoggedOut, setIsLoggedOut] = useState(false); - // Wallet state const [{ wallet, connecting }, connect, disconnect] = useConnectWallet(); + 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); - // Handle wallet connection + // Check session and listen to auth state changes useEffect(() => { - if (wallet?.provider && !isLoggedOut) { // Chỉ đăng nhập nếu chưa logout + const checkExistingSession = async () => { + 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]; - setAccount({ - address, - ens: ens?.name || null, - }); - const userData = { - walletAddress: address, - name: ens?.name || formatWalletAddress(address), // Sử dụng ENS nếu có, nếu không thì dùng địa chỉ ví rút gọn - }; - localStorage.setItem('currentUser', JSON.stringify(userData)); - window.location.href = '/'; - } - }, [wallet, router, isLoggedOut]); + setAccount({ address, ens: ens?.name || null }); - // Helper functions (using localStorage for demo purposes) - const validateEmail = (email: string) => { - const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return re.test(email.toLowerCase()); - }; + 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) { + 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); + } - const getUsers = () => { - if (typeof window !== 'undefined') { - const usersJSON = localStorage.getItem('users'); - return usersJSON ? JSON.parse(usersJSON) : []; - } - return []; - }; + updateProfile({ + username: ens?.name || formatWalletAddress(address), + profileImage: null, + backgroundImage: null, + }); + addWallet(address); + await syncWithSupabase(); - const isEmailExists = (email: string) => { - const users = getUsers(); - return users.some((user: { email: string }) => user.email === email); - }; + const publicUserData = { + walletAddress: address, + name: ens?.name || formatWalletAddress(address), + isLoggedIn: true, + }; + const encryptedUserData = encryptData(JSON.stringify(publicUserData)); + localStorage.setItem("userDisplayInfo", encryptedUserData); - const validatePasswordForUser = (email: string, password: string) => { - const users = getUsers(); - const user = users.find( - (user: { email: string; password: string }) => user.email === email - ); - return user && user.password === password; - }; + console.log("Wallet login successful, redirecting to dashboard"); + toast.success("Successfully authenticated with wallet"); + router.push("/"); + } 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); + } + }; - const handleSubmit = (e: React.FormEvent) => { + authenticateWithWallet(); + } + }, [wallet, router, isLoggedOut, signInWithWalletConnect, updateProfile, addWallet, syncWithSupabase]); + + // Handle email/password login + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setEmailError(''); - setPasswordError(''); + setEmailError(""); + setPasswordError(""); + setIsLoading(true); - let valid = true; + try { + 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); + return; + } - if (!validateEmail(email)) { - setEmailError('Please enter a valid email address.'); - valid = false; - } else if (!isEmailExists(email)) { - setEmailError('Email does not exist.'); - valid = false; - } + if (!data.user || !data.session) { + toast.error("Something went wrong with the login"); + return; + } - if (valid && !validatePasswordForUser(email, password)) { - setPasswordError('Incorrect password.'); - valid = false; - } + const { data: profileData } = await supabase + .from("profiles") + .select("*") + .eq("id", data.user.id) + .single(); + + updateProfile({ + username: profileData?.display_name || email.split("@")[0], + profileImage: profileData?.profile_image || null, + backgroundImage: profileData?.background_image || null, + }); + await syncWithSupabase(); - if (valid) { - // Save the current user to localStorage for use in the header - const users = getUsers(); - const loggedInUser = users.find( - (user: { email: string; password: string }) => user.email === email - ); - localStorage.setItem('currentUser', JSON.stringify(loggedInUser)); - window.location.href = "/"; - window.location.reload(); + const publicUserData = { + id: data.user.id, + name: profileData?.display_name || email.split("@")[0], + email, + isLoggedIn: true, + }; + 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: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error("Login error:", error); + toast.error(`An unexpected error occurred: ${errorMessage}`); + } finally { + setIsLoading(false); } }; - const handleWalletConnect = () => { + // Handle wallet connect/disconnect with debounce + const handleWalletConnect = debounce(async () => { if (!wallet) { - connect(); // Kết nối ví nếu chưa kết nối + await connect(); } else { - disconnect({ label: wallet.label }); // Ngắt kết nối ví + await disconnect({ label: wallet.label }); setAccount(null); - setIsLoggedOut(true); // Đánh dấu đã logout - localStorage.removeItem('currentUser'); - router.push('/login'); // Chuyển hướng về trang login + setIsLoggedOut(true); + await supabase.auth.signOut(); + localStorage.removeItem("userDisplayInfo"); + localStorage.removeItem("userToken"); + router.push("/login"); } - }; + }, 1000); return ( <> @@ -258,14 +318,12 @@ function LoginPageContent() {

Welcome back

- Login to your{' '} + Login to your{" "} CryptoPath account

- + setEmail(e.target.value)} + disabled={isLoading} /> {emailError && {emailError}}
- +
setPassword(e.target.value)} + disabled={isLoading} />
- {passwordError && ( - {passwordError} - )} + {passwordError && {passwordError}}
Or continue with
-
- {/* Social login buttons (icons only) */} - +
@@ -398,44 +411,28 @@ function LoginPageContent() { id="connectButton" type="button" onClick={handleWalletConnect} - disabled={connecting} + disabled={connecting || isLoading} className="flex items-center justify-center w-full border border-white rounded-md py-2 px-4 hover:bg-gray-800" > - - + + Login with Wallet
- Don't have an account?{' '} - - Sign up - + Don't have an account?{" "} + Sign up
- By clicking continue, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - . + By clicking continue, you agree to our{" "} + Terms of Service{" "} + and{" "} + Privacy Policy.
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 d8c6a5e..0b912ea 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,15 +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 @@ -18,98 +39,128 @@ type Language = 'en' | 'vi'; // Translation object const translations = { en: { - vietnamPremierCrypto: "Vietnam's Premier Crypto Platform", - joinAllInOne: "Join the all-in-one crypto ", - appInVietnam: "app in Vietnam", + vietnamPremierCrypto: "Vietnam's Premier Blockchain Explorer", + joinAllInOne: "Your all-in-one crypto ", + appInVietnam: "transaction explorer", emailPlaceholder: "Your Email Address...", signUpSuccess: "Sign Up Successfully!", processing: "Processing...", tryCryptoPath: "Try CryptoPath", - tradeLikePro: "Trade like ", - aPro: "a pro", - getLowestFees: "Get the lowest fees, fastest transactions, powerful APIs, and more", - oneApplication: "One Application. ", - infinitePotential: "Infinite Potential", - exploreNFTMarketplace: "Explore the world's best NFT marketplace, DEX, and wallets supporting all your favorite chains.", - exploreDecentralized: "Explore decentralized applications and experience cutting-edge blockchain technology.", - exchange: "Exchange", - web3: "Web3", - accompanyingYou: "Accompanying You ", - everyStep: "Every Step of the Way", - fromCryptoTransactions: "From cryptocurrency transactions to your first NFT purchase, CryptoPath will guide you through the entire process.", - believeInYourself: "Believe in yourself and never stop learning.", + tradeLikePro: "Track transactions ", + aPro: "like never before", + getLowestFees: "Real-time transaction monitoring, comprehensive analytics, and powerful visualization tools", + oneApplication: "One Platform. ", + infinitePotential: "Complete Insights", + exploreNFTMarketplace: "Track real-time cryptocurrency transactions, monitor market trends, and analyze blockchain metrics with our comprehensive dashboard.", + exploreDecentralized: "Explore detailed transaction histories, wallet analytics, and network statistics with our powerful blockchain explorer.", + exchange: "Analytics", + web3: "Explorer", + accompanyingYou: "Your Gateway to ", + everyStep: "Blockchain Data", + fromCryptoTransactions: "From real-time transaction tracking to comprehensive market analysis, CryptoPath provides you with all the tools you need to understand blockchain activity.", + believeInYourself: "Make informed decisions with data-driven insights.", meetTheTeam: "Meet the ", team: "Team", - willingToListen: "We are always willing to listen to everyone!", - whatIsCryptoPath: "What is ", + willingToListen: "Dedicated to building the best blockchain explorer!", + whatIsCryptoPath: "Why ", cryptoPath: "CryptoPath?", - hearFromTopIndustry: "Hear from top industry leaders to understand", - whyCryptoPathIsFavorite: "why CryptoPath is everyone's favorite application.", + hearFromTopIndustry: "A powerful blockchain explorer that helps you", + whyCryptoPathIsFavorite: "track, analyze, and understand cryptocurrency transactions.", learnMore: "Learn More", - whatIsCryptocurrency: "What is Cryptocurrency?", - explainingNewCurrency: "Explaining the \"new currency of the world\"", - redefiningSystem: "Redefining the system", - welcomeToWeb3: "Welcome to Web3", - whatIsBlockchain: "What is Blockchain?", - understandBlockchain: "Understand how Blockchain works", + whatIsCryptocurrency: "Real-Time Analytics", + explainingNewCurrency: "Track market trends and transaction flows", + redefiningSystem: "Transaction Explorer", + welcomeToWeb3: "Monitor blockchain activity in real-time", + whatIsBlockchain: "Network Statistics", + understandBlockchain: "Comprehensive blockchain metrics and insights", trustedBy: "Trusted", - industryLeaders: "by industry leaders", - testimonialText: "\"CryptoPath is an amazing platform for tracking transactions. I can't even picture what the world would be like without it\"", + industryLeaders: "by crypto enthusiasts", + testimonialText: "\"CryptoPath provides the most comprehensive and user-friendly blockchain explorer I've ever used. The real-time analytics and transaction tracking are invaluable.\"", founderOf: "Founder of CryptoPath", - readyToStart: "Ready to start your crypto journey?", - joinThousands: "Join thousands of Vietnamese users who are already trading, investing, and earning with CryptoPath.", - downloadNow: "Download Now", + readyToStart: "Ready to explore the blockchain?", + joinThousands: "Join thousands of users who are already using CryptoPath to track and analyze cryptocurrency transactions.", + downloadNow: "Start Exploring", 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 Crypto hàng đầu Việt Nam", - joinAllInOne: "Tham gia ứng dụng crypto ", - appInVietnam: "tất cả trong một ở Việt Nam", + vietnamPremierCrypto: "Nền Tảng Khám Phá Blockchain Hàng Đầu Việt Nam", + joinAllInOne: "Nền tảng theo dõi giao dịch ", + appInVietnam: "tiền điện tử toàn diện", emailPlaceholder: "Địa chỉ Email của bạn...", signUpSuccess: "Đăng ký thành công!", processing: "Đang xử lý...", - tryCryptoPath: "Dùng thử CryptoPath", - tradeLikePro: "Giao dịch như ", - aPro: "chuyên gia", - getLowestFees: "Nhận phí thấp nhất, giao dịch nhanh nhất, API mạnh mẽ và nhiều hơn nữa", - oneApplication: "Một ứng dụng. ", - infinitePotential: "Tiềm năng vô hạn", - exploreNFTMarketplace: "Khám phá thị trường NFT, DEX tốt nhất thế giới và ví hỗ trợ tất cả các chuỗi yêu thích của bạn.", - exploreDecentralized: "Khám phá các ứng dụng phi tập trung và trải nghiệm công nghệ blockchain tiên tiến.", - exchange: "Sàn giao dịch", - web3: "Web3", - accompanyingYou: "Đồng hành cùng bạn ", - everyStep: "trong từng bước đi", - fromCryptoTransactions: "Từ giao dịch tiền điện tử đến việc mua NFT đầu tiên, CryptoPath sẽ hướng dẫn bạn qua toàn bộ quá trình.", - believeInYourself: "Hãy tin vào chính mình và không ngừng học hỏi.", + tryCryptoPath: "Thử CryptoPath", + tradeLikePro: "Theo dõi giao dịch ", + aPro: "theo cách mới", + getLowestFees: "Giám sát giao dịch thời gian thực, phân tích toàn diện và công cụ trực quan mạnh mẽ", + oneApplication: "Một nền tảng. ", + infinitePotential: "Thông tin đầy đủ", + exploreNFTMarketplace: "Theo dõi giao dịch tiền điện tử thời gian thực, giám sát xu hướng thị trường và phân tích các chỉ số blockchain với bảng điều khiển toàn diện của chúng tôi.", + exploreDecentralized: "Khám phá lịch sử giao dịch chi tiết, phân tích ví và thống kê mạng lưới với công cụ khám phá blockchain mạnh mẽ của chúng tôi.", + exchange: "Phân tích", + web3: "Khám phá", + accompanyingYou: "Cổng thông tin ", + everyStep: "Blockchain của bạn", + fromCryptoTransactions: "Từ theo dõi giao dịch thời gian thực đến phân tích thị trường toàn diện, CryptoPath cung cấp cho bạn tất cả các công cụ cần thiết để hiểu hoạt động blockchain.", + believeInYourself: "Đưa ra quyết định dựa trên dữ liệu thực tế.", meetTheTeam: "Gặp gỡ ", team: "Đội ngũ", - willingToListen: "Chúng tôi luôn sẵn sàng lắng nghe mọi người!", - whatIsCryptoPath: "CryptoPath ", - cryptoPath: "là gì?", - hearFromTopIndustry: "Lắng nghe từ các nhà lãnh đạo hàng đầu trong ngành để hiểu", - whyCryptoPathIsFavorite: "tại sao CryptoPath là ứng dụng yêu thích của mọi người.", + willingToListen: "Luôn nỗ lực xây dựng nền tảng khám phá blockchain tốt nhất!", + whatIsCryptoPath: "Tại sao chọn ", + cryptoPath: "CryptoPath?", + hearFromTopIndustry: "Một công cụ khám phá blockchain mạnh mẽ giúp bạn", + whyCryptoPathIsFavorite: "theo dõi, phân tích và hiểu các giao dịch tiền điện tử.", learnMore: "Tìm hiểu thêm", - whatIsCryptocurrency: "Tiền điện tử là gì?", - explainingNewCurrency: "Giải thích về \"đồng tiền mới của thế giới\"", - redefiningSystem: "Định nghĩa lại hệ thống", - welcomeToWeb3: "Chào mừng đến với Web3", - whatIsBlockchain: "Blockchain là gì?", - understandBlockchain: "Hiểu cách Blockchain hoạt động", + whatIsCryptocurrency: "Phân tích thời gian thực", + explainingNewCurrency: "Theo dõi xu hướng thị trường và luồng giao dịch", + redefiningSystem: "Khám phá giao dịch", + welcomeToWeb3: "Giám sát hoạt động blockchain theo thời gian thực", + whatIsBlockchain: "Thống kê mạng lưới", + understandBlockchain: "Số liệu và thông tin blockchain toàn diện", trustedBy: "Được tin dùng", - industryLeaders: "bởi các nhà lãnh đạo ngành", - testimonialText: "\"CryptoPath là một nền tảng tuyệt vời để theo dõi giao dịch. Tôi thậm chí không thể tưởng tượng thế giới sẽ như thế nào nếu không có nó\"", + industryLeaders: "bởi cộng đồng crypto", + testimonialText: "\"CryptoPath cung cấp công cụ khám phá blockchain toàn diện và thân thiện nhất mà tôi từng sử dụng. Phân tích thời gian thực và theo dõi giao dịch là vô giá.\"", founderOf: "Nhà sáng lập CryptoPath", - readyToStart: "Sẵn sàng bắt đầu hành trình tiền điện tử của bạn?", - joinThousands: "Tham gia cùng hàng nghìn người dùng Việt Nam đang giao dịch, đầu tư và kiếm tiền với CryptoPath.", - downloadNow: "Tải xuống ngay", + readyToStart: "Sẵn sàng khám phá blockchain?", + joinThousands: "Tham gia cùng hàng nghìn người dùng đang sử dụng CryptoPath để theo dõi và phân tích giao dịch tiền điện tử.", + downloadNow: "Bắt đầu khám phá", 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á" } }; @@ -143,7 +194,7 @@ const teamMembers = [ }, ]; -const HomePage = () => { +const LandingPage = () => { const [activeTab, setActiveTab] = useState('sgd'); const [email, setEmail] = useState(''); const [emailError, setEmailError] = useState(''); @@ -151,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 @@ -188,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; } @@ -213,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 @@ -221,59 +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} -

} -
- +
+
+ + + - + + + + + +
@@ -286,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} -

-
- - -
-
-
-
+ {/* Demo Showcase Section */} + - {/* Evolution Illustration 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/app/search-offchain/TransactionContent.tsx b/app/search-offchain/TransactionContent.tsx index 1c30afb..b1136f8 100644 --- a/app/search-offchain/TransactionContent.tsx +++ b/app/search-offchain/TransactionContent.tsx @@ -1,11 +1,11 @@ 'use client' -import WalletInfo from "@/components/WalletInfo" -import TransactionGraphOffChain from "@/components/TransactionGraphOffChain" -import TransactionTableOffChain from "@/components/TransactionTableOffChain" -import Portfolio from "@/components/Portfolio" +import WalletInfo from "@/components/search/WalletInfo" +import TransactionGraphOffChain from "@/components/search-offchain/TransactionGraphOffChain" +import TransactionTableOffChain from "@/components/search-offchain/TransactionTableOffChain" +import Portfolio from "@/components/search/Portfolio" import { useSearchParams } from "next/navigation" -import SearchBarOffChain from "@/components/SearchBarOffChain" +import SearchBarOffChain from "@/components/search-offchain/SearchBarOffChain" export default function Transactions() { diff --git a/app/search/TransactionContent.tsx b/app/search/TransactionContent.tsx index 499b8a5..6c49ed4 100644 --- a/app/search/TransactionContent.tsx +++ b/app/search/TransactionContent.tsx @@ -1,43 +1,183 @@ +// transactioncontent.tsx +// Search page content with wallet info, transaction graph, table, and NFT gallery 'use client' -import SearchBar from "@/components/SearchBar" -import WalletInfo from "@/components/WalletInfo" -import TransactionGraph from "@/components/TransactionGraph" -import TransactionTable from "@/components/TransactionTable" -import Portfolio from "@/components/Portfolio" -import NFTGallery from "@/components/NFTGallery" +import SearchBar from "@/components/search/SearchBar" +import WalletInfo from "@/components/search/WalletInfo" +import { default as TransactionGraph } from "@/components/search/TransactionGraph" +import TransactionTable from "@/components/search/TransactionTable" +import Portfolio from "@/components/search/Portfolio" +import NFTGallery from "@/components/search/NFTGallery" import { useSearchParams } from "next/navigation" +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { ErrorCard } from "@/components/ui/error-card" +import AddressErrorCard from "@/components/search/AddressErrorCard" +// Ethereum address validation regex pattern +const ETH_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; export default function Transactions() { const searchParams = useSearchParams() + const router = useRouter() const address = searchParams.get("address") + const networkParam = searchParams.get("network") || "mainnet" + const providerParam = searchParams.get("provider") || "etherscan" + const [network, setNetwork] = useState(networkParam) + const [provider, setProvider] = useState(providerParam) + const [pendingTxCount, setPendingTxCount] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [addressError, setAddressError] = useState(null) + + // Update network and provider state when URL parameters change + useEffect(() => { + setNetwork(networkParam) + setProvider(providerParam) + }, [networkParam, providerParam]) + + // Validate address on component mount and when address changes + useEffect(() => { + if (address) { + if (!ETH_ADDRESS_REGEX.test(address)) { + setAddressError(`"${address}" is not a valid Ethereum address. Address must start with 0x followed by 40 hexadecimal characters.`); + } else { + setAddressError(null); + } + } else { + setAddressError(null); + } + }, [address]); + + // Get available networks based on selected provider + const getAvailableNetworks = () => { + if (provider === "infura") { + return [ + { value: "mainnet", label: "Ethereum Mainnet" }, + { value: "optimism", label: "Optimism" }, + { value: "arbitrum", label: "Arbitrum" }, + ]; + } else { + // Default Etherscan only supports Ethereum mainnet + return [ + { value: "mainnet", label: "Ethereum Mainnet" }, + ]; + } + }; + + // Handle network change + const handleNetworkChange = (value: string) => { + setNetwork(value) + + // Update the URL to include the new network + if (address) { + router.push(`/search?address=${address}&network=${value}&provider=${provider}`) + } else { + router.push(`/search?network=${value}&provider=${provider}`) + } + } + + // Handle provider change + const handleProviderChange = (value: string) => { + setIsLoading(true); + setProvider(value); + + // Get available networks for the new provider + const availableNetworks = getAvailableNetworks().map(net => net.value); + + // If current network is not available in the new provider, use first available + let newNetwork = network; + if (!availableNetworks.includes(network)) { + newNetwork = availableNetworks[0]; + } + + setNetwork(newNetwork); + + // Update the URL + if (address) { + router.push(`/search?address=${address}&network=${newNetwork}&provider=${value}`) + } else { + router.push(`/search?network=${newNetwork}&provider=${value}`) + } + + setTimeout(() => { + setIsLoading(false); + }, 500); + } + + // Fetch pending transactions for the current network + useEffect(() => { + if (network) { + const baseUrl = typeof window !== 'undefined' + ? window.location.origin + : process.env.NEXT_PUBLIC_URL || '' + + fetch(`${baseUrl}/api/pending?network=${network}`) + .then(res => res.json()) + .then(data => { + if (!data.error) { + setPendingTxCount(data.pendingTransactions) + } + }) + .catch(err => { + console.error("Error fetching pending transactions:", err) + }) + } + }, [network]) + + const availableNetworks = getAvailableNetworks(); + + // Function to render appropriate content + const renderContent = () => { + // If we have an address but it's invalid + if (address && addressError) { + return ; + } + + // If we have a valid address, render the wallet info + if (address) { + return ( + <> +
+ + +
+
+ +
+ + + + ); + } + + // Default welcome screen + return ( +
+

Welcome to CryptoPath

+

+ Enter an Ethereum address above to explore wallet details, transactions, and NFTs. +

+

+ Currently connected to: {network} using {provider} + {pendingTxCount !== null && ( + ({pendingTxCount} pending transactions) + )} +

+
+ ); + }; + return (
- -
- {address ? ( - <> -
-
- - -
- -
- - - - ) : ( -
-

Welcome to CryptoPath

-

- Enter an Ethereum address above to explore wallet details, transactions, and NFTs. -

+
+
- )} +
+ + {renderContent()}
) diff --git a/app/setting/page.tsx b/app/setting/page.tsx new file mode 100644 index 0000000..8e44bf2 --- /dev/null +++ b/app/setting/page.tsx @@ -0,0 +1,30 @@ + +'use client'; + +import React from 'react'; +import { SettingsProvider } from '@/components/context/SettingsContext'; +import SettingLayout from '@/components/setting_ui/SettingLayout'; +import ProfileSection from '@/components/setting_ui/ProfileSection'; +import WalletSection from '@/components/setting_ui/WalletSection'; +import SettingSync from '@/components/setting_ui/SettingSync'; +import ParticlesBackground from '@/components/ParticlesBackground'; +import ClientLayout from '@/components/ClientLayout'; + +const Settings = () => { + return ( +
+ + + + } + walletSection={} + syncSection={} + /> + + +
+ ); +}; + +export default Settings; diff --git a/app/signup/page.tsx b/app/signup/page.tsx index 6e593a7..7bb00c3 100644 --- a/app/signup/page.tsx +++ b/app/signup/page.tsx @@ -1,9 +1,12 @@ + 'use client'; import Link from 'next/link'; import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import ParticlesBackground from '@/components/ParticlesBackground'; +import { toast } from 'sonner'; +import { supabase } from '@/src/integrations/supabase/client'; export default function SignupPage() { const router = useRouter(); @@ -19,36 +22,17 @@ export default function SignupPage() { const [confirmPasswordError, setConfirmPasswordError] = useState(''); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); - - - // Helper functions: using localStorage to store users (as in your original code) + // Helper function for email validation const validateEmail = (email: string) => { const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return re.test(email.toLowerCase()); }; - const getUsers = () => { - if (typeof window !== 'undefined') { - const usersJSON = localStorage.getItem('users'); - return usersJSON ? JSON.parse(usersJSON) : []; - } - return []; - }; - - const isEmailExists = (email: string) => { - const users = getUsers(); - return users.some((user: { email: string }) => user.email === email); - }; - - const saveUser = (user: { name: string; email: string; password: string }) => { - const users = getUsers(); - users.push(user); - localStorage.setItem('users', JSON.stringify(users)); - }; - - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + // Reset error messages setNameError(''); setEmailError(''); @@ -61,37 +45,62 @@ export default function SignupPage() { setNameError('Please enter your full name.'); valid = false; } + if (!validateEmail(email)) { setEmailError('Please enter a valid email address.'); valid = false; - } else if (isEmailExists(email)) { - setEmailError('Email already exists.'); - valid = false; } + if (password.length < 8) { setPasswordError('Password must be at least 8 characters long.'); valid = false; } + if (password !== confirmPassword) { setConfirmPasswordError('Passwords do not match.'); valid = false; } if (valid) { - const newUser = { - name: name.trim(), - email: email.trim(), - password: password, - }; - saveUser(newUser); - alert('Sign up successful!'); - router.push('/login'); // or redirect to login if you prefer + setIsLoading(true); + + try { + // Register the user with Supabase Auth + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + full_name: name.trim(), + }, + }, + }); + + if (error) { + if (error.message.includes('email')) { + setEmailError(error.message); + } else if (error.message.includes('password')) { + setPasswordError(error.message); + } else { + toast.error(error.message); + } + return; + } + + toast.success('Sign up successful! Please check your email for verification.'); + router.push('/login'); + } catch (error) { + console.error('Signup error:', error); + toast.error('An unexpected error occurred. Please try again.'); + } finally { + setIsLoading(false); + } } }; return ( <> -
+
{/* Signup Form */}
@@ -120,6 +129,7 @@ export default function SignupPage() { className="w-full px-3 py-2 border border-white bg-black text-white rounded-md" value={name} onChange={(e) => setName(e.target.value)} + disabled={isLoading} /> {nameError && {nameError}}
@@ -135,6 +145,7 @@ export default function SignupPage() { className="w-full px-3 py-2 border border-white bg-black text-white rounded-md" value={email} onChange={(e) => setEmail(e.target.value)} + disabled={isLoading} /> {emailError && {emailError}}
@@ -150,11 +161,13 @@ export default function SignupPage() { className="w-full px-3 py-2 border border-white bg-black text-white rounded-md pr-10" value={password} onChange={(e) => setPassword(e.target.value)} + disabled={isLoading} /> - -
+ window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + // Email change handler + const handleEmailChange = (e: React.ChangeEvent) => { + setEmail(e.target.value); + setEmailError(''); + setIsSuccess(false); + }; + + // Subscribe handler (matches page.tsx approach) + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Email validation + if (!email) { + setEmailError("Email is required"); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + setEmailError("Please enter a valid email address"); + return; + } + + try { + setIsSubmitting(true); + + // Call API to register email + const response = await fetch('/api/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, source: 'footer' }), + }); - {/* Middle Section: Navigation Links */} -
- {/* About Us */} + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to subscribe"); + } + + // Handle success + setEmail(''); + setIsSuccess(true); + toast.success("Thanks for subscribing! You'll receive our updates soon."); + + } catch (error) { + console.error('Subscription error:', error); + toast.error(error instanceof Error ? error.message : "Subscription failed. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + // Scroll to top handler + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + return ( +
+
+ {/* Top Section: Featured Content + Newsletter */} +
+ {/* Branding Side */} +
+
+ CryptoPath Logo + + CryptoPath© + +
+ +

+ Your gateway to blockchain insights. Track transactions, explore markets, and discover NFTs with our comprehensive crypto explorer. +

+ +
+ + + + + + + + + + + + +
+
+ + {/* Newsletter Side */} +
+
+

Stay Updated

+

+ Get the latest news, updates and crypto insights delivered straight to your inbox. +

+
+
+
+ + {emailError && ( +

{emailError}

+ )} + {isSuccess && ( +

Subscribed successfully!

+ )} +
+
+ +
+
+
+
+ + {/* Main Navigation Links */} +
+ {/* Platform Links */}
-

About Us

+

Platform

    -
  • Our Story
  • -
  • Team
  • -
  • Careers
  • -
  • Press
  • +
  • + + + Market Overview + +
  • +
  • + + + Price Table + +
  • +
  • + + + Transactions + +
  • +
  • + + + NFT Explorer + +
  • +
  • + + + Blockchain Search + +
- - {/* Products */} + + {/* Resources */}
-

Products

+

Resources

    -
  • Buy Crypto
  • -
  • P2P Trading
  • -
  • Trade
  • -
  • Convert
  • +
  • + + + Documentation + +
  • +
  • + + + API Reference + +
  • +
  • + + + Blog + +
  • +
  • + + + Learning Hub + +
  • +
  • + + + Support + +
- - {/* Resources */} + + {/* Company */}
-

Resources

+

Company

    -
  • Blog
  • -
  • Help Center
  • -
  • API Docs
  • -
  • Support
  • +
  • + + + About Us + +
  • +
  • + + + Team + +
  • +
  • + + + Careers + +
  • +
  • + + + Press + +
  • +
  • + + + Contact + +
- + {/* Legal */}

Legal

    -
  • Privacy Policy
  • -
  • Terms of Service
  • -
  • Cookie Policy
  • +
  • + + + Terms of Service + +
  • +
  • + + + Privacy Policy + +
  • +
  • + + + Cookie Policy + +
  • +
  • + + + Disclaimer + +
- - {/* Bottom Section: Social Links & Copyright */} -
-

© 2024 CryptoPath. All rights reserved.

-
- - - - - - - - - - - - - - - - + + {/* External Resources */} +
+

Blockchain Resources

+
+ {[ + { name: "Ethereum", url: "https://ethereum.org" }, + { name: "CoinGecko", url: "https://coingecko.com" }, + { name: "Etherscan", url: "https://etherscan.io" }, + { name: "Binance", url: "https://binance.com" }, + { name: "CoinMarketCap", url: "https://coinmarketcap.com" }, + { name: "OpenSea", url: "https://opensea.io" }, + ].map((resource, i) => ( + + {resource.name} + + + ))} +
+
+ + {/* Bottom Section */} +
+

© {new Date().getFullYear()} CryptoPath. All rights reserved.

+
+ Privacy + Terms + Cookies + FAQ + + Developers +
+ + {/* Back to top button */} + + +
- ) -} + ); +}; + export default Footer; diff --git a/components/Header.tsx b/components/Header.tsx index 8630229..6914b0b 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,63 +1,116 @@ -"use client"; // Ensures this runs on the client side +"use client"; + import { useState, useRef, useEffect } from "react"; import Link from "next/link"; -import { Menu, X, Search } from "lucide-react"; // Icons +import { Menu, X, Search } from "lucide-react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { Input } from "@/components/ui/input"; import { LoadingScreen } from "@/components/loading-screen"; +import { useSettings } from "@/components/context/SettingsContext"; +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)}`; +}; const Header = () => { const [isOpen, setIsOpen] = useState(false); const [address, setAddress] = useState(""); - const [searchType, setSearchType] = useState<"onchain" | "offchain">("onchain"); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); - const [currentUser, setCurrentUser] = useState<{ walletAddress?: string; name?: string } | null>(null); + const [currentUser, setCurrentUser] = useState<{ + id?: string; + email?: string; + name?: string; + } | null>(null); const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); + const { profile } = useSettings(); + // Fetch and sync user state with Supabase Auth useEffect(() => { - const updateCurrentUser = () => { - if (typeof window !== "undefined") { - const storedUser = localStorage.getItem("currentUser"); - if (storedUser) { - setCurrentUser(JSON.parse(storedUser)); - } else { - setCurrentUser(null); - } + const fetchUser = async () => { + const { data: { session } } = await supabase.auth.getSession(); + + if (session) { + const user = session.user; + setCurrentUser({ + id: user.id, + 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, + name: user.user_metadata?.full_name || user.email?.split("@")[0], + isLoggedIn: true, + settingsKey: `settings_${user.email}` + })); + } else { + setCurrentUser(null); + localStorage.removeItem('currentUser'); } }; - // Cập nhật khi component mount - updateCurrentUser(); - - // Lắng nghe sự kiện storage (khi localStorage thay đổi ở tab khác) - window.addEventListener("storage", updateCurrentUser); + fetchUser(); - // Tùy chọn: Lắng nghe thay đổi trong cùng tab (nếu cần) - const interval = setInterval(updateCurrentUser, 1000); // Kiểm tra mỗi 1s + // Listen for auth state changes + const { data: authListener } = supabase.auth.onAuthStateChange( + (event: AuthChangeEvent, session: Session | null) => { + if (event === "SIGNED_IN" && session) { + const user = session.user; + setCurrentUser({ + id: user.id, + 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, + name: user.user_metadata?.full_name || user.email?.split("@")[0], + isLoggedIn: true, + settingsKey: `settings_${user.email}` + })); + } else if (event === "SIGNED_OUT") { + setCurrentUser(null); + localStorage.removeItem("currentUser"); + } + } + ); return () => { - window.removeEventListener("storage", updateCurrentUser); - clearInterval(interval); + authListener?.subscription.unsubscribe(); }; }, []); + // Handle dropdown click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + const handleSearch = async (event: React.FormEvent) => { event.preventDefault(); if (!address.trim()) return; - if (address) { - router.push(`/search/?address=${address}`); - } - setIsLoading(true); - try { - // Simulate loading time (can be replaced with actual API call) - await new Promise(resolve => setTimeout(resolve, 2500)); + await new Promise((resolve) => setTimeout(resolve, 2500)); // Simulated delay if (searchType === "onchain") { router.push(`/search/?address=${encodeURIComponent(address)}`); } else { @@ -70,38 +123,47 @@ const Header = () => { } }; - const clearAddress = () => { - setAddress(""); + const handleSettingClick = () => { + router.push("/setting"); + setDropdownOpen(false); }; - - // Navigate to search page when clicking the search icon - const handleSearchIconClick = () => { - router.push('/search'); + const handlePortfolioClick = () => { + router.push("/portfolio"); + setDropdownOpen(false); }; - const handleLogout = () => { - localStorage.removeItem("currentUser"); - setCurrentUser(null); - setDropdownOpen(false); - router.push("/login"); - // Thông báo cho người dùng ngắt kết nối ví thủ công (tùy chọn) - if (typeof window !== "undefined" && (window as any).ethereum) { - console.log("Please disconnect your wallet manually in MetaMask."); - // Hoặc hiển thị một thông báo UI nếu cần + const clearAddress = () => setAddress(""); + + const handleSearchIconClick = () => router.push("/search"); + + const handleLogout = async () => { + try { + const { error } = await supabase.auth.signOut(); + if (error) throw error; + localStorage.removeItem("currentUser"); // Clean up if used + setCurrentUser(null); + setDropdownOpen(false); + toast.success("Logged out successfully"); + router.push("/login"); + if (typeof window !== "undefined" && (window as any).ethereum) { + console.log("Please disconnect your wallet manually in MetaMask."); + } + } catch (error) { + console.error("Logout error:", error); + toast.error("Failed to log out. Please try again."); } }; - const formatWalletAddress = (walletAddress: string) => { - if (!walletAddress) return ""; - return `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`; - }; + const displayName = + profile?.username && profile.username !== "User" + ? profile.username + : currentUser?.name || currentUser?.email?.split("@")[0] || ""; return ( <>
- {/* Logo */} -
-

+
+

{ height={75} className="inline-block mr-2" /> - CryptoPath© + Crypto + Path© +

- {/* Desktop Navigation */} -

- {/* Loading Screen */} ); }; -export default Header; \ No newline at end of file +export default Header; 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 new file mode 100644 index 0000000..71f1bd4 --- /dev/null +++ b/components/NFT/MintForm.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect } from 'react'; +import { utils } from 'ethers'; +import { Loader2, CloudUpload, Wallet } from 'lucide-react'; + +interface MintFormProps { + onSubmit: (recipient: string, tokenURI: string) => void; + processing?: boolean; + checkWhitelist: (address: string) => Promise; +} + +export default function MintForm({ + onSubmit, + processing, + checkWhitelist +}: MintFormProps) { + const [tokenURI, setTokenURI] = useState(''); + const [recipient, setRecipient] = useState(''); + const [isWhitelisted, setIsWhitelisted] = useState(false); + + // Validate Ethereum address + const isValidAddress = (address: string) => utils.isAddress(address); + + // Validate IPFS/HTTP URI + const isValidURI = (uri: string) => uri.startsWith('ipfs://') || uri.startsWith('https://'); + + // Check whitelist status when recipient changes + useEffect(() => { + const verifyWhitelist = async () => { + if (isValidAddress(recipient)) { + const status = await checkWhitelist(recipient); + setIsWhitelisted(status); + } else { + setIsWhitelisted(false); + } + }; + verifyWhitelist(); + }, [recipient, checkWhitelist]); + + // Combined disable conditions - remove !isWhitelisted since tab is already hidden for non-whitelisted users + const isDisabled = processing || + !isValidAddress(recipient) || + !isValidURI(tokenURI); + + // Get button state - remove whitelist check since user must be whitelisted to see this form + const getButtonState = () => { + if (processing) return { + disabled: true, + className: 'bg-gray-700 cursor-not-allowed text-gray-400' + }; + if (!isValidAddress(recipient) || !isValidURI(tokenURI)) return { + disabled: true, + className: 'bg-gray-700 cursor-not-allowed text-gray-400' + }; + return { + disabled: false, + className: 'bg-gradient-to-r from-orange-500 to-purple-600 hover:from-orange-600 hover:to-purple-700 text-white shadow-lg hover:shadow-orange-400/20' + }; + }; + + const buttonState = getButtonState(); + + return ( +
+ {/* Header Section */} +
+
+ +

Mint

+
+

+ Only whitelisted addresses can mint NFTs +

+
+ + {/* IPFS Guidance Section */} + + + {/* Input Section */} +
+ {/* Recipient Address Input */} +
+ + 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" + /> + {!isValidAddress(recipient) && recipient !== '' && ( +

+ ⚠ Invalid BSC address +

+ )} +
+ + {/* Metadata URI Input */} +
+ + 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" + /> + {!isValidURI(tokenURI) && tokenURI !== '' && ( +

+ ⚠ URI must start with ipfs:// or https:// +

+ )} +
+
+ + {/* Mint Button */} + + + {/* Metadata Example */} +
+

Example metadata format:

+
+          
+            {`{
+  "name": "CryptoPunk #9999",
+  "description": "A rare digital artifact",
+  "image": "ipfs://Qm...",
+  "attributes": [
+    {"trait_type": "Rarity", "value": "Legendary"},
+    {"trait_type": "Collection", "value": "CryptoPath Genesis"}
+  ]
+}`}
+          
+        
+
+
+ ); +} diff --git a/components/NFT/NFTCard.tsx b/components/NFT/NFTCard.tsx index 43fd518..b348364 100644 --- a/components/NFT/NFTCard.tsx +++ b/components/NFT/NFTCard.tsx @@ -15,7 +15,7 @@ interface NFTCardProps { isListed?: boolean; }; mode: 'market' | 'owned' | 'listing'; - onAction: (tokenId: string, price?: string) => void; // Thêm optional param + onAction: (tokenId: string, price?: string) => void; processing?: boolean; } @@ -25,30 +25,40 @@ export default function NFTCard({ nft, mode, onAction, processing }: NFTCardProp const [imgError, setImgError] = useState(false); const [isSeller, setIsSeller] = useState(false); - // Theo dõi thay đổi account và seller + // Helper to format the price (removes trailing zeros like 23.0000 -> 23) + const formatPrice = (price: string) => parseFloat(price).toString(); + + // Check if the current wallet address is the seller useEffect(() => { - const checkSeller = () => { - const sellerMatch = nft.seller?.toLowerCase() === account?.toLowerCase(); - setIsSeller(!!sellerMatch); // Thêm ép kiểu boolean - }; - checkSeller(); + const sellerMatch = nft.seller?.toLowerCase() === account?.toLowerCase(); + setIsSeller(!!sellerMatch); }, [account, nft.seller]); - // Xử lý URL ảnh từ IPFS + const [isImageLoading, setIsImageLoading] = useState(true); + + // Convert ipfs:// URL to a gateway URL with fallbacks const formatImageUrl = (ipfsUrl?: string) => { if (!ipfsUrl) return '/fallback-nft.png'; if (ipfsUrl.startsWith('http')) return ipfsUrl; + const cid = ipfsUrl.replace('ipfs://', '').split('/')[0]; - return `https://gateway.pinata.cloud/ipfs/${cid}`; + const gateways = [ + `https://gateway.pinata.cloud/ipfs/${cid}`, + `https://cloudflare-ipfs.com/ipfs/${cid}`, + `https://ipfs.io/ipfs/${cid}` + ]; + + // Try multiple gateways if one fails + return gateways[Math.floor(Math.random() * gateways.length)]; }; - // Hiển thị nút hành động + // Render the appropriate action button with solid color styling const getActionButton = () => { if (processing) { return ( ); @@ -87,7 +97,7 @@ export default function NFTCard({ nft, mode, onAction, processing }: NFTCardProp return ( @@ -97,7 +107,7 @@ export default function NFTCard({ nft, mode, onAction, processing }: NFTCardProp return isSeller ? (
- {/* Content */}
- {/* Image Container */} + {/* Image section with loading state */}
+ {imageLoading && ( +
+
+
+ )} + {nft.name} { - (e.target as HTMLImageElement).src = '/fallback-nft.png'; - }} + className={`object-cover transition-opacity duration-300 ${ + imageLoading ? 'opacity-0' : 'opacity-100' + }`} + onLoadingComplete={() => setImageLoading(false)} + onError={handleImageError} + priority + sizes="(max-width: 768px) 100vw, 50vw" /> + + {imgError && failedGateways.length === 4 && ( +
+

+ Failed to load image from all available IPFS gateways +

+
+ )}
- {/* Details Section */} + {/* Details section */}
- {/* Description */} + {/* Description with XSS protection */}
-

Description

-

+

Description

+

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

- {/* Price */} + {/* Price display */}
-

Price

+

Price

- {parseFloat(nft.price).toFixed(4)} PATH + {formatPrice(nft.price)} PATH

- {/* Additional Info */} + {/* Seller/Owner info */}

Seller

-

+

{nft.seller.slice(0, 6)}...{nft.seller.slice(-4)} -

+

Owner

-

+

{nft.owner.slice(0, 6)}...{nft.owner.slice(-4)} -

+
- {/* Purchase Button */} + {/* Purchase button */}
+ ); +} diff --git a/components/NFT/NFTNavigation.tsx b/components/NFT/NFTNavigation.tsx new file mode 100644 index 0000000..0cdf8c9 --- /dev/null +++ b/components/NFT/NFTNavigation.tsx @@ -0,0 +1,317 @@ +import React, { useState } from 'react'; +import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { ArrowRight, BadgePercent, Grid3X3, Wallet, ExternalLink, Search, ShoppingBag, Tag, BarChart2 } from 'lucide-react'; +import { useWallet } from '@/components/Faucet/walletcontext'; +import { Input } from '@/components/ui/input'; +import { useRouter } from 'next/navigation'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Badge } from '@/components/ui/badge'; + +interface NavCardProps { + icon: React.ReactNode; + title: string; + description: string; + href: string; + color: string; + delay: number; + isActive?: boolean; + image?: string; +} + +const NavCard = ({ icon, title, description, href, color, delay, isActive, image }: NavCardProps) => ( + + + + {image && ( +
+ {title} +
+
+ )} + +
+ {icon} +
+

{title}

+

{description}

+
+ Explore + +
+
+
+ +
+); + +interface NFTNavigationProps { + activeSection: 'marketplace' | 'collections'; + pathBalance: string; + hasWallet: boolean; +} + +export default function NFTNavigation({ + activeSection, + pathBalance, + hasWallet +}: NFTNavigationProps) { + const router = useRouter(); + const { connectWallet, account } = useWallet(); + const [searchQuery, setSearchQuery] = useState(''); + const [showSectionMenu, setShowSectionMenu] = useState(false); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (!searchQuery.trim()) return; + + if (activeSection === 'marketplace') { + router.push(`/NFT?search=${encodeURIComponent(searchQuery)}`); + } else { + if (/^0x[a-fA-F0-9]{40}$/.test(searchQuery)) { + router.push(`/NFT/collection/${searchQuery}`); + } else { + router.push(`/NFT/collection?search=${encodeURIComponent(searchQuery)}`); + } + } + }; + + return ( +
+ {/* Navigation Bar */} + + +
+ {/* Section Tabs */} +
+
+ + + Marketplace + + {activeSection === 'marketplace' && ( +
+ )} + + {/* Marketplace Dropdown */} +
+
+ + + PATH NFT Market + + + + My NFTs + + + + My Listings + +
+
+
+ +
+ + + Collections + + {activeSection === 'collections' && ( +
+ )} + + {/* Collections Dropdown */} +
+
+ + + Popular Collections + + + + My Collections + +
+
+
+ + Art + Art Collections + + + Gaming + Gaming Collections + +
+
+
+
+ + {/* Search Form */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 bg-gray-800/50 border-gray-700 w-full" + /> +
+ +
+ + {/* 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/NFTTabs.tsx b/components/NFT/NFTTabs.tsx index 6090615..0ea50dd 100644 --- a/components/NFT/NFTTabs.tsx +++ b/components/NFT/NFTTabs.tsx @@ -1,60 +1,86 @@ // components/NFT/NFTTabs.tsx -import { ReactNode } from 'react'; +import React from 'react'; -// Sửa type props để đảm bảo type safety -type TabButtonProps = { - children: ReactNode; - active: boolean; - onClick: () => void; - count?: number; -}; - -const TabButton = ({ children, active, onClick, count }: TabButtonProps) => ( - -); - -// Sửa type cho component NFTTabs -type NFTTabsProps = { - activeTab: 'market' | 'owned' | 'listings'; - setActiveTab: (tab: 'market' | 'owned' | 'listings') => void; +interface NFTTabsProps { + activeTab: 'market' | 'owned' | 'listings' | 'mint' | 'whitelist'; + setActiveTab: (tab: 'market' | 'owned' | 'listings' | 'mint' | 'whitelist') => void; balances: { market: number; owned: number; listings: number }; -}; + showMintTab: boolean; + showWhitelistTab: boolean; +} -export default function NFTTabs({ activeTab, setActiveTab, balances }: NFTTabsProps) { +export default function NFTTabs({ + activeTab, + setActiveTab, + balances, + showMintTab, + showWhitelistTab +}: NFTTabsProps) { return ( -
- + {/* Market Tab */} + - setActiveTab('listings')} - count={balances.listings} + className={`px-6 py-2 rounded-full transition-all ${ + activeTab === 'listings' + ? 'bg-orange-400 text-black font-bold' + : 'bg-gray-800 hover:bg-gray-700 text-gray-300' + }`} > My Listings ({balances.listings}) - + + + {/* Mint Tab */} + {showMintTab && ( + + )} + + {/* Whitelist Tab */} + {showWhitelistTab && ( + + )}
); } \ No newline at end of file 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 new file mode 100644 index 0000000..9cc88d9 --- /dev/null +++ b/components/NFT/WhitelistForm.tsx @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react'; +import { utils } from 'ethers'; +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 (newStatus: boolean) => { + if (!isValidAddress(address)) { + setErrorMessage('Invalid address format'); + return; + } + + setProcessing(true); + setStatus(null); + setErrorMessage(''); + + try { + 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); + } + }; + + if (!isOwner) return null; + + return ( +
+
+

Whitelist Management

+

Owner only: Add/remove addresses from whitelist

+
+ +
+
+ + setAddress(e.target.value)} + placeholder="0x..." + 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 +

+ )} +
+ +
+ + + +
+ + {status === 'success' && ( +

+ + Operation successful! +

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

+ + {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()} + +
+ ))} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/NFTGallery.tsx b/components/NFTGallery.tsx deleted file mode 100644 index 3c89c3f..0000000 --- a/components/NFTGallery.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client" - -import { useSearchParams } from "next/navigation" -import { useEffect, useState } from "react" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Loader2 } from "lucide-react" -import Image from "next/image" - -interface NFT { - tokenID: string - tokenName: string - tokenSymbol: string - contractAddress: string -} - -export default function NFTGallery() { - const searchParams = useSearchParams() - const address = searchParams.get("address") - const [nfts, setNFTs] = useState([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - useEffect(() => { - if (address) { - setLoading(true) - setError(null) - fetch(`/api/nfts?address=${address}`) - .then((res) => res.json()) - .then((data) => { - if (data.error) { - throw new Error(data.error) - } - setNFTs(data) - }) - .catch((err) => { - console.error("Error fetching NFTs:", err) - setError(err.message || "Failed to fetch NFTs") - }) - .finally(() => setLoading(false)) - } - }, [address]) - - if (!address) { - return null // Don't render anything if there's no address - } - - if (loading) { - return ( - - - - - - ) - } - - if (error) { - return ( - - -

Error: {error}

-
-
- ) - } - - if (nfts.length === 0) { - return ( - - - NFT Gallery - - No NFTs found for this address. - - ) - } - - return ( - - - NFT Gallery - - -
- {nfts.map((nft) => ( - - - {nft.tokenName} -

{nft.tokenName}

-

#{nft.tokenID}

-
-
- ))} -
-
-
- ) -} - diff --git a/components/ParticlesBackground.tsx b/components/ParticlesBackground.tsx index caa38b8..32ca153 100644 --- a/components/ParticlesBackground.tsx +++ b/components/ParticlesBackground.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import Script from 'next/script'; // Extend the global window interface to include particlesJS @@ -20,7 +20,7 @@ const particlesConfig = { }, }, color: { - value: "FF0000", + value: "#f5b056", }, shape: { type: "circle", @@ -36,7 +36,7 @@ const particlesConfig = { line_linked: { enable: true, distance: 150, - color: "#FF0000", + color: "#ffc259", opacity: 0.4, width: 1, }, @@ -50,7 +50,7 @@ const particlesConfig = { }, }, interactivity: { - detect_on: "window", + detect_on: "window", // Changed from "window" to "canvas" for better performance events: { onhover: { enable: true, @@ -77,25 +77,45 @@ const particlesConfig = { }; const ParticlesBackground = () => { + const [scriptLoaded, setScriptLoaded] = useState(false); + const initParticles = () => { - if (window.particlesJS) { - window.particlesJS("particles-js", particlesConfig); + try { + console.log("Initializing particles..."); + if (window.particlesJS && document.getElementById('particles-js')) { + window.particlesJS("particles-js", particlesConfig); + console.log("Particles initialized successfully"); + setScriptLoaded(true); + } else { + console.error("particlesJS not available or particles-js element not found"); + } + } catch (error) { + console.error("Error initializing particles:", error); } }; useEffect(() => { - if (typeof window !== "undefined" && "particlesJS" in window) - { + // Try to initialize if the script is already loaded + if (typeof window !== "undefined" && "particlesJS" in window) { + console.log("particlesJS found in window - initializing"); + // Add a small delay to ensure DOM is ready + const timer = setTimeout(() => { initParticles(); - } + }, 100); + return () => clearTimeout(timer); + } }, []); return ( <>