-
-
Price
-
+
+
+ Price:{' '}
+
{mode === 'owned' && !nft.isListed ? (
'Not Listed'
) : (
<>
- {parseFloat(nft.price || '0').toFixed(4)}
+ {formatPrice(nft.price || '0')}
PATH
>
)}
-
-
+
+
+
+
{nft.seller && (
-
-
Seller
-
+ Seller:
+
@@ -160,10 +175,10 @@ export default function NFTCard({ nft, mode, onAction, processing }: NFTCardProp
)}
{nft.owner && (
-
- Owner
-
+ Owner:
+
{`${nft.owner.slice(0, 6)}...${nft.owner.slice(-4)}`}
@@ -172,16 +187,16 @@ export default function NFTCard({ nft, mode, onAction, processing }: NFTCardProp
)}
- {/* Nút hành động */}
- {getActionButton()}
+ {/* Action button */}
+ {getActionButton()}
- {/* Badge trạng thái */}
+ {/* Badge if listed */}
{mode === 'owned' && nft.isListed && (
-
);
-}
\ No newline at end of file
+}
diff --git a/components/NFT/NFTTabs.tsx b/components/NFT/NFTTabs.tsx
index 6090615..afd4704 100644
--- a/components/NFT/NFTTabs.tsx
+++ b/components/NFT/NFTTabs.tsx
@@ -1,60 +1,91 @@
// components/NFT/NFTTabs.tsx
-import { ReactNode } from 'react';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { motion } from 'framer-motion';
+import { useRef, useLayoutEffect, useState } 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) => (
-
- {children}
-
-);
-
-// Sửa type cho component NFTTabs
-type NFTTabsProps = {
+interface NFTTabsProps {
activeTab: 'market' | 'owned' | 'listings';
setActiveTab: (tab: 'market' | 'owned' | 'listings') => void;
- balances: { market: number; owned: number; listings: number };
-};
+ balances: {
+ market: number;
+ owned: number;
+ listings: number;
+ };
+}
export default function NFTTabs({ activeTab, setActiveTab, balances }: NFTTabsProps) {
- return (
-
- setActiveTab('market')}
- count={balances.market}
- >
- Marketplace
-
-
- setActiveTab('owned')}
- count={balances.owned}
- >
- My NFTs ({balances.owned})
-
+ // Use non-null assertions for the refs.
+ const marketRef = useRef(null!);
+ const ownedRef = useRef(null!);
+ const listingsRef = useRef(null!);
+ const containerRef = useRef(null!);
- setActiveTab('listings')}
- count={balances.listings}
- >
- My Listings ({balances.listings})
-
-
+ // State for the indicator's left position and width.
+ const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
+
+ useLayoutEffect(() => {
+ let activeRef;
+ if (activeTab === 'market') {
+ activeRef = marketRef;
+ } else if (activeTab === 'owned') {
+ activeRef = ownedRef;
+ } else {
+ activeRef = listingsRef;
+ }
+ if (activeRef.current && containerRef.current) {
+ // Calculate the left offset relative to the container and the button's width.
+ setIndicatorStyle({
+ left: activeRef.current.offsetLeft,
+ width: activeRef.current.offsetWidth,
+ });
+ }
+ }, [activeTab, balances]);
+
+ return (
+
+
+
+ setActiveTab('market')}
+ className="
+ px-4 py-2 rounded-full text-sm font-medium text-white transition-colors
+ hover:text-white
+ "
+ >
+ Market ({balances.market})
+
+ setActiveTab('owned')}
+ className="
+ px-4 py-2 rounded-full text-sm font-medium text-white transition-colors
+ hover:text-white
+ "
+ >
+ Owned ({balances.owned})
+
+ setActiveTab('listings')}
+ className="
+ px-4 py-2 rounded-full text-sm font-medium text-white transition-colors
+ hover:text-white
+ "
+ >
+ Listings ({balances.listings})
+
+
+ {/* Animated indicator */}
+
+
+
);
-}
\ 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.tokenID}
-
-
- ))}
-
-
-
- )
-}
-
diff --git a/components/ParticlesBackground.tsx b/components/ParticlesBackground.tsx
index b7e434b..9eaee0d 100644
--- a/components/ParticlesBackground.tsx
+++ b/components/ParticlesBackground.tsx
@@ -13,14 +13,14 @@ declare global {
const particlesConfig = {
particles: {
number: {
- value: 100,
+ value: 35,
density: {
enable: true,
value_area: 800,
},
},
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,
},
@@ -57,7 +57,7 @@ const particlesConfig = {
mode: "grab",
},
onclick: {
- enable: true,
+ enable: false,
mode: "push",
},
},
@@ -114,4 +114,4 @@ const ParticlesBackground = () => {
);
};
-export default ParticlesBackground;
+export default ParticlesBackground;
\ No newline at end of file
diff --git a/components/Portfolio.tsx b/components/Portfolio.tsx
deleted file mode 100644
index 3b18637..0000000
--- a/components/Portfolio.tsx
+++ /dev/null
@@ -1,100 +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 { Coins } from "lucide-react"
-
-interface TokenBalance {
- token: string
- balance: string
- usdValue: number
-}
-
-export default function Portfolio() {
- const searchParams = useSearchParams()
- const address = searchParams.get("address")
- const [portfolio, setPortfolio] = useState([])
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
-
- useEffect(() => {
- if (address) {
- setLoading(true)
- setError(null)
-
- // In a real application, you would fetch this data from your API
- // This is just mock data for demonstration purposes
- setTimeout(() => {
- setPortfolio([
- { token: "ETH", balance: "1.5", usdValue: 3000 },
- { token: "USDT", balance: "500", usdValue: 500 },
- { token: "LINK", balance: "100", usdValue: 1000 },
- ])
- setLoading(false)
- }, 1000)
- }
- }, [address])
-
- if (loading) {
- return (
-
-
-
- )
- }
-
- if (error) {
- return (
-
-
- Error: {error}
-
-
- )
- }
-
- if (portfolio.length === 0) {
- return null
- }
-
- const totalValue = portfolio.reduce((sum, token) => sum + token.usdValue, 0)
-
- return (
-
-
- Portfolio
-
-
-
-
-
- Token
- Balance
- USD Value
-
-
-
- {portfolio.map((token) => (
-
- {token.token}
- {token.balance}
- ${token.usdValue.toFixed(2)}
-
- ))}
-
-
-
-
- Total Value:
-
- ${totalValue.toFixed(2)}
-
-
-
-
-
- )
-}
-
diff --git a/components/SearchBarOffChain.tsx b/components/SearchBarOffChain.tsx
deleted file mode 100644
index 67668c4..0000000
--- a/components/SearchBarOffChain.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-"use client"
-
-import { useState } from "react"
-import { Input } from "@/components/ui/input"
-import { Button } from "@/components/ui/button"
-import { useRouter } from "next/navigation"
-import { Search } from "lucide-react"
-
-export default function SearchBarOffChain() {
- const [address, setAddress] = useState("")
- const router = useRouter()
-
- const handleSearch = (e: React.FormEvent) => {
- e.preventDefault()
- if (address) {
- router.push(`/search-offchain/?address=${address}`)
- }
- }
-
- return (
-
- )
-}
-
diff --git a/components/SearchOnTop.tsx b/components/SearchOnTop.tsx
new file mode 100644
index 0000000..1206150
--- /dev/null
+++ b/components/SearchOnTop.tsx
@@ -0,0 +1,86 @@
+'use client';
+import React, { useState } from 'react';
+import { useRouter } from "next/navigation";
+import { FaSearch, FaMapPin, FaSun, FaEthereum } from 'react-icons/fa';
+import { LoadingScreen } from "@/components/loading-screen";
+
+const SearchOnTop = () => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [searchType, setSearchType] = useState<"onchain" | "offchain">("onchain");
+ const router = useRouter();
+
+ const handleSearch = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!searchQuery.trim()) return;
+
+ setIsLoading(true);
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 2500)); // Simulated delay
+ if (searchType === "onchain") {
+ router.push(`/search/?address=${encodeURIComponent(searchQuery)}&network=mainnet`);
+ } else {
+ router.push(`/search-offchain/?address=${encodeURIComponent(searchQuery)}`);
+ }
+ } catch (error) {
+ console.error("Search error:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <>
+
+ {/* ETH Price and Gas Data */}
+
+ ETH Price: $1,931.60 (+1.41%)
+ Gas: 0.462 Gwei
+
+
+ {/* Search Bar */}
+
+
+
+
+ setSearchType(e.target.value as "onchain" | "offchain")}
+ className="bg-white border border-gray-300 text-gray-700 py-1 px-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ On-Chain
+ Off-Chain
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default SearchOnTop;
\ No newline at end of file
diff --git a/components/TransactionGraph.tsx b/components/TransactionGraph.tsx
deleted file mode 100644
index e2807d3..0000000
--- a/components/TransactionGraph.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-"use client";
-
-import { useSearchParams, useRouter } from "next/navigation";
-import { useEffect, useState, useCallback } from "react";
-import dynamic from "next/dynamic";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Loader2} from "lucide-react";
-
-// Dynamically import ForceGraph2D (without generic type arguments)
-const ForceGraph2D = dynamic(() => import("react-force-graph-2d"), { ssr: false });
-
-interface Transaction {
- id: string;
- from: string;
- to: string;
- value: string;
- timestamp: string;
-}
-
-// Define our node type with our custom properties.
-export interface GraphNode {
- id: string;
- label: string;
- color: string;
- type: string;
- x?: number;
- y?: number;
- vx?: number;
- vy?: number;
- fx?: number;
- fy?: number;
-}
-
-interface GraphData {
- nodes: GraphNode[];
- links: { source: string; target: string; value: number }[];
-}
-
-const getRandomColor = () => `#${Math.floor(Math.random() * 16777215).toString(16)}`;
-
-function shortenAddress(address: string): string {
- return `${address.slice(0, 3)}...${address.slice(-2)}`;
-}
-
-// A mock function to get a name for an address (replace with your actual logic)
-function getNameForAddress(address: string): string | null {
- const mockNames: { [key: string]: string } = {
- "0x1234567890123456789012345678901234567890": "Alice",
- "0x0987654321098765432109876543210987654321": "Bob",
- };
- return mockNames[address] || null;
-}
-
-export default function TransactionGraph() {
- const searchParams = useSearchParams();
- const router = useRouter();
- const address = searchParams.get("address");
- const [graphData, setGraphData] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (address) {
- setLoading(true);
- setError(null);
- fetch(`/api/transactions?address=${address}&offset=50`)
- .then((res) => res.json())
- .then((data: unknown) => {
- if (!Array.isArray(data)) {
- throw new Error((data as any).error || "Unexpected API response");
- }
- const transactions = data as Transaction[];
- const nodes = new Map();
- const links: GraphData["links"] = [];
-
- transactions.forEach((tx) => {
- if (!nodes.has(tx.from)) {
- const name = getNameForAddress(tx.from);
- nodes.set(tx.from, {
- id: tx.from,
- label: name || shortenAddress(tx.from),
- color: getRandomColor(),
- type: tx.from === address ? "out" : "in",
- });
- }
- if (!nodes.has(tx.to)) {
- const name = getNameForAddress(tx.to);
- nodes.set(tx.to, {
- id: tx.to,
- label: name || shortenAddress(tx.to),
- color: getRandomColor(),
- type: tx.to === address ? "in" : "out",
- });
- }
- links.push({
- source: tx.from,
- target: tx.to,
- value: Number.parseFloat(tx.value),
- });
- });
-
- setGraphData({
- nodes: Array.from(nodes.values()),
- links,
- });
- })
- .catch((err) => {
- console.error("Error fetching transaction data for graph:", err);
- setError(err.message || "Failed to fetch transaction data for graph");
- })
- .finally(() => setLoading(false));
- }
- }, [address]);
-
- // Update onNodeClick to accept both the node and the MouseEvent.
- const handleNodeClick = useCallback(
- (node: { [others: string]: any }, event: MouseEvent) => {
- const n = node as GraphNode;
- router.push(`/search/?address=${n.id}`);
- },
- [router]
- );
-
- // Update nodes to reflect their transaction type ("both" if a node has both incoming and outgoing links)
- useEffect(() => {
- if (graphData) {
- const updatedNodes: GraphNode[] = graphData.nodes.map((node) => {
- const incoming = graphData.links.filter(link => link.target === node.id);
- const outgoing = graphData.links.filter(link => link.source === node.id);
- if (incoming.length > 0 && outgoing.length > 0) {
- // Explicitly assert that the type is the literal "both"
- return { ...node, type: "both" as "both" };
- }
- return node;
- });
- if (JSON.stringify(updatedNodes) !== JSON.stringify(graphData.nodes)) {
- // Use the existing graphData rather than a functional update.
- setGraphData({
- ...graphData,
- nodes: updatedNodes,
- });
- }
- }
- }, [graphData]);
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- if (error) {
- return (
-
-
- Error: {error}
-
-
- );
- }
-
- if (!graphData) {
- return null;
- }
-
- return (
-
-
- Transaction Graph
-
-
- node.id) as any}
- nodeColor={((node: GraphNode) => node.color) as any}
- nodeCanvasObject={
- ((node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
- if (node.x == null || node.y == null) return;
- const { label, type, x, y } = node;
- const fontSize = 4;
- ctx.font = `${fontSize}px Sans-Serif`;
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.beginPath();
- ctx.arc(x, y, type === "both" ? 4 : 3, 0, 2 * Math.PI, false);
- ctx.fillStyle =
- type === "in"
- ? "rgba(0, 255, 0, 0.5)"
- : type === "out"
- ? "rgba(255, 0, 0, 0.5)"
- : "rgba(255, 255, 0, 0.5)";
- ctx.fill();
- ctx.fillStyle = "white";
- ctx.fillText(label, x, y);
- }) as any
- }
- nodeRelSize={6}
- linkWidth={1}
- linkColor={() => "rgb(255, 255, 255)"}
- linkDirectionalParticles={2}
- linkDirectionalParticleWidth={3}
- linkDirectionalParticleSpeed={0.005}
- d3VelocityDecay={0.3}
- d3AlphaDecay={0.01}
- onNodeClick={handleNodeClick}
- width={580}
- height={440}
- />
-
-
- );
-}
diff --git a/components/WalletInfo.tsx b/components/WalletInfo.tsx
deleted file mode 100644
index 44b9ebd..0000000
--- a/components/WalletInfo.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 { Wallet, Coins, DollarSign, ListOrdered } from "lucide-react"
-
-interface WalletData {
- address: string
- balance: string
- transactionCount: number
-}
-
-export default function WalletInfo() {
- const searchParams = useSearchParams()
- const address = searchParams.get("address")
- const [walletData, setWalletData] = useState(null)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [usdValue, setUsdValue] = useState(null)
-
- useEffect(() => {
- if (address) {
- setLoading(true)
- setError(null)
-
- Promise.all([
- fetch(`/api/wallet?address=${address}`).then((res) => res.json()),
- fetch("/api/eth-usd-rate").then((res) => res.json()),
- ])
- .then(([walletData, rateData]) => {
- if (walletData.error) throw new Error(walletData.error)
- if (rateData.error) throw new Error(rateData.error)
-
- setWalletData(walletData)
- const ethBalance = Number.parseFloat(walletData.balance.split(" ")[0])
- setUsdValue(ethBalance * rateData.rate)
- })
- .catch((err) => {
- console.error("Error fetching wallet data:", err)
- setError("Failed to fetch wallet data")
- })
- .finally(() => setLoading(false))
- }
- }, [address])
-
- if (loading) {
- return (
-
-
-
- )
- }
-
- if (error) {
- return (
-
-
- Error: {error}
-
-
- )
- }
-
- if (!walletData) {
- return null
- }
-
- return (
-
-
- Wallet Information
-
-
-
-
-
-
- Address: {" "}
- {walletData.address}
-
-
-
-
-
-
Balance: {walletData.balance}
-
-
- {usdValue !== null && (
-
-
-
USD Value: ${usdValue.toFixed(2)}
-
- )}
-
-
-
-
Transaction Count: {walletData.transactionCount}
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/components/context/SettingsContext.tsx b/components/context/SettingsContext.tsx
new file mode 100644
index 0000000..d82d468
--- /dev/null
+++ b/components/context/SettingsContext.tsx
@@ -0,0 +1,281 @@
+"use client";
+
+import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
+import { supabase } from "@/src/integrations/supabase/client";
+import { toast } from "sonner";
+import { Database } from "@/src/integrations/supabase/types";
+type ProfileData = Database["public"]["Tables"]["profiles"]["Row"];
+type ProfileSettings = {
+ username: string;
+ profileImage: string | null;
+ backgroundImage: string | null;
+};
+
+type Wallet = {
+ address: string;
+ isDefault: boolean;
+};
+
+type SettingsContextType = {
+ profile: ProfileSettings;
+ wallets: Wallet[];
+ updateProfile: (profile: Partial) => void;
+ saveProfile: () => void;
+ addWallet: (address: string) => void;
+ removeWallet: (address: string) => void; // Sửa để dùng address thay vì id
+ setDefaultWallet: (address: string) => void; // Sửa để dùng address thay vì id
+ hasUnsavedChanges: boolean;
+ isSyncing: boolean;
+ syncWithSupabase: () => Promise;
+};
+
+const defaultProfile: ProfileSettings = {
+ username: "User",
+ profileImage: null,
+ backgroundImage: null,
+};
+
+const SettingsContext = createContext(undefined);
+
+export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
+ const [userId, setUserId] = useState(null);
+ const [profile, setProfile] = useState(defaultProfile);
+ const [savedProfile, setSavedProfile] = useState(defaultProfile);
+ const [wallets, setWallets] = useState([]);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+ const [isSyncing, setIsSyncing] = useState(false);
+
+ // Lấy userId từ session
+ useEffect(() => {
+ const getCurrentUserInfo = async () => {
+ const { data: { session } } = await supabase.auth.getSession();
+ setUserId(session?.user?.id || null);
+ };
+ getCurrentUserInfo();
+
+ const { data: authListener } = supabase.auth.onAuthStateChange((_, session) => {
+ setUserId(session?.user?.id || null);
+ });
+
+ return () => {
+ authListener.subscription.unsubscribe();
+ };
+ }, []);
+
+ // Tải settings từ Supabase
+ useEffect(() => {
+ const loadSettings = async () => {
+ if (!userId) return;
+
+ try {
+ const { data: profileData, error: profileError } = await supabase
+ .from("profiles")
+ .select("*")
+ .eq("id", userId)
+ .single();
+
+ if (profileError && profileError.code === "PGRST116") {
+ const newProfile = {
+ id: userId,
+ display_name: defaultProfile.username,
+ profile_image: null,
+ background_image: null,
+ wallets: [],
+ updated_at: new Date().toISOString(),
+ };
+ await supabase.from("profiles").insert(newProfile);
+ setProfile(defaultProfile);
+ setSavedProfile(defaultProfile);
+ } else if (profileData) {
+ const profileSettings: ProfileSettings = {
+ username: profileData.display_name || "User",
+ profileImage: profileData.profile_image || null,
+ backgroundImage: profileData.background_image || null,
+ };
+ setProfile(profileSettings);
+ setSavedProfile(profileSettings);
+
+ // Tải wallets từ cột wallets
+ const walletData = profileData.wallets || [];
+ const formattedWallets: Wallet[] = walletData.map((wallet: any) => ({
+ address: wallet.address,
+ isDefault: wallet.is_default || false,
+ }));
+ setWallets(formattedWallets);
+ }
+ } catch (error) {
+ console.error("Error loading settings:", error);
+ toast.error("Failed to load settings");
+ }
+ };
+
+ loadSettings();
+ }, [userId]);
+
+ // Kiểm tra thay đổi
+ useEffect(() => {
+ const isChanged = JSON.stringify(profile) !== JSON.stringify(savedProfile);
+ setHasUnsavedChanges(isChanged);
+ }, [profile, savedProfile]);
+
+ const updateProfile = (updates: Partial) => {
+ setProfile((prev) => ({ ...prev, ...updates }));
+ };
+
+ const saveProfile = async () => {
+ if (!userId) {
+ toast.error("You must be logged in to save settings");
+ return;
+ }
+ try {
+ await supabase
+ .from("profiles")
+ .update({
+ display_name: profile.username,
+ profile_image: profile.profileImage,
+ background_image: profile.backgroundImage,
+ updated_at: new Date().toISOString(),
+ wallets: wallets.map((w) => ({ address: w.address, is_default: w.isDefault })),
+ })
+ .eq("id", userId);
+ setSavedProfile(profile);
+ toast.success("Profile saved successfully");
+ } catch (error) {
+ console.error("Error saving profile:", error);
+ toast.error("Failed to save profile");
+ }
+ };
+
+ const addWallet = async (address: string) => {
+ if (!userId) {
+ toast.error("You must be logged in to add a wallet");
+ return;
+ }
+ if (wallets.some((w) => w.address.toLowerCase() === address.toLowerCase())) {
+ toast.error("This wallet address is already added");
+ return;
+ }
+
+ const newWallet: Wallet = {
+ address,
+ isDefault: wallets.length === 0,
+ };
+ const updatedWallets = [...wallets, newWallet];
+ setWallets(updatedWallets);
+
+ try {
+ await supabase
+ .from("profiles")
+ .update({
+ wallets: updatedWallets.map((w) => ({ address: w.address, is_default: w.isDefault })),
+ updated_at: new Date().toISOString(),
+ })
+ .eq("id", userId);
+ toast.success("Wallet added successfully");
+ } catch (error) {
+ console.error("Error adding wallet:", error);
+ toast.error("Failed to add wallet");
+ }
+ };
+
+ const removeWallet = async (address: string) => {
+ if (!userId) {
+ toast.error("You must be logged in to remove a wallet");
+ return;
+ }
+ const updatedWallets = wallets.filter((w) => w.address !== address);
+ if (wallets.find((w) => w.address === address)?.isDefault && updatedWallets.length > 0) {
+ updatedWallets[0].isDefault = true;
+ }
+ setWallets(updatedWallets);
+
+ try {
+ await supabase
+ .from("profiles")
+ .update({
+ wallets: updatedWallets.map((w) => ({ address: w.address, is_default: w.isDefault })),
+ updated_at: new Date().toISOString(),
+ })
+ .eq("id", userId);
+ toast.success("Wallet removed successfully");
+ } catch (error) {
+ console.error("Error removing wallet:", error);
+ toast.error("Failed to remove wallet");
+ }
+ };
+
+ const setDefaultWallet = async (address: string) => {
+ if (!userId) {
+ toast.error("You must be logged in to set a default wallet");
+ return;
+ }
+ const updatedWallets = wallets.map((w) => ({
+ ...w,
+ isDefault: w.address === address,
+ }));
+ setWallets(updatedWallets);
+
+ try {
+ await supabase
+ .from("profiles")
+ .update({
+ wallets: updatedWallets.map((w) => ({ address: w.address, is_default: w.isDefault })),
+ updated_at: new Date().toISOString(),
+ })
+ .eq("id", userId);
+ toast.success("Default wallet updated");
+ } catch (error) {
+ console.error("Error setting default wallet:", error);
+ toast.error("Failed to update default wallet");
+ }
+ };
+
+ const syncWithSupabase = async () => {
+ if (!userId) {
+ toast.error("You must be logged in to sync settings");
+ return;
+ }
+ setIsSyncing(true);
+ try {
+ await supabase
+ .from("profiles")
+ .update({
+ display_name: profile.username,
+ profile_image: profile.profileImage,
+ background_image: profile.backgroundImage,
+ wallets: wallets.map((w) => ({ address: w.address, is_default: w.isDefault })),
+ updated_at: new Date().toISOString(),
+ })
+ .eq("id", userId);
+ toast.success("Settings synchronized with database");
+ } catch (error) {
+ console.error("Error syncing settings:", error);
+ toast.error("Failed to sync settings with database");
+ } finally {
+ setIsSyncing(false);
+ }
+ };
+
+ const value = {
+ profile,
+ wallets,
+ updateProfile,
+ saveProfile,
+ addWallet,
+ removeWallet,
+ setDefaultWallet,
+ hasUnsavedChanges,
+ isSyncing,
+ syncWithSupabase,
+ };
+
+ return {children} ;
+};
+
+export const useSettings = () => {
+ const context = useContext(SettingsContext);
+ if (context === undefined) {
+ throw new Error("useSettings must be used within a SettingsProvider");
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/components/home/CryptoExplorer.tsx b/components/home/CryptoExplorer.tsx
new file mode 100644
index 0000000..8fce486
--- /dev/null
+++ b/components/home/CryptoExplorer.tsx
@@ -0,0 +1,247 @@
+import React, { useState, useEffect } from 'react';
+import { useRouter } from "next/navigation"
+
+const CryptoPathExplorer = ({ language = 'en' as 'en' | 'vi' }) => {
+ const [searchValue, setSearchValue] = useState('');
+ const [ethPrice, setEthPrice] = useState('');
+ const [ethChange, setEthChange] = useState('');
+ const [opPrice, setOpPrice] = useState('');
+ const [opChange, setOpChange] = useState('');
+ const [isFilterOpen, setIsFilterOpen] = useState(false);
+ const [selectedFilter, setSelectedFilter] = useState('All Filters');
+ const router = useRouter()
+
+ const filters = ['All Filters', 'On-Chain', 'Off-Chain', 'Tokens', 'NFTs', 'Addresses'];
+
+ const translations = {
+ en: {
+ searchPlaceholder: "Search by Address / Txn Hash / Block / Token",
+ ethPrice: "ETH Price:",
+ latestBlock: "LATEST BLOCK",
+ transactions: "TRANSACTIONS",
+ l1TxnBatch: "LATEST L1 TXN BATCH",
+ l1StateBatch: "LATEST L1 STATE BATCH",
+ transactionHistory: "OP MAINNET TRANSACTION HISTORY IN 14 DAYS",
+ opPrice: "OP PRICE"
+ },
+ vi: {
+ searchPlaceholder: "Tìm kiếm theo Địa chỉ / Mã Giao dịch / Khối / Token",
+ ethPrice: "Giá ETH:",
+ latestBlock: "KHỐI MỚI NHẤT",
+ transactions: "GIAO DỊCH",
+ l1TxnBatch: "LÔ GIAO DỊCH L1 MỚI NHẤT",
+ l1StateBatch: "LÔ TRẠNG THÁI L1 MỚI NHẤT",
+ transactionHistory: "LỊCH SỬ GIAO DỊCH OP MAINNET TRONG 14 NGÀY",
+ opPrice: "GIÁ OP"
+ }
+ };
+
+ const t = translations[language];
+
+ // Fetch data from CoinGecko when the component mounts
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ // CoinGecko API endpoint for Ethereum and Optimism prices with 24h changes
+ const response = await fetch(
+ 'https://api.coingecko.com/api/v3/simple/price?ids=ethereum,optimism&vs_currencies=usd&include_24hr_change=true'
+ );
+ const data = await response.json();
+ // Set ETH data
+ if (data.ethereum) {
+ setEthPrice(data.ethereum.usd.toFixed(2));
+ // Format change with a "+" sign if positive
+ const change = data.ethereum.usd_24h_change;
+ setEthChange((change >= 0 ? '+' : '') + change.toFixed(2) + '%');
+ }
+ // Set Optimism data
+ if (data.optimism) {
+ setOpPrice(data.optimism.usd.toFixed(6));
+ const change = data.optimism.usd_24h_change;
+ setOpChange((change >= 0 ? '+' : '') + change.toFixed(2) + '%');
+ }
+ } catch (error) {
+ console.error("Error fetching crypto data: ", error);
+ }
+ };
+ fetchData();
+ }, []);
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+// Nho minh duy phan nay
+ };
+
+ const toggleFilterDropdown = () => {
+ setIsFilterOpen(!isFilterOpen);
+ };
+
+ const selectFilter = (filter: string) => {
+ setSelectedFilter(filter);
+ setIsFilterOpen(false);
+ };
+
+ return (
+
+ {/* Explorer Title */}
+
+
CryptoPath Explorer
+
+
+ {/* Search Bar */}
+
+
+
+ {/* Filter Dropdown */}
+
+
+ {selectedFilter}
+
+
+
+
+
+ {isFilterOpen && (
+
+ {filters.map((filter) => (
+
selectFilter(filter)}
+ >
+ {filter}
+
+ ))}
+
+ )}
+
+
+ {/* Search Input */}
+
+
+
+
+
+ {/* Data Display */}
+
+
+ {/* ETH Price */}
+
+
+
+
ETH PRICE
+
+ ${ethPrice || 'Loading...'}
+
+ ({ethChange || '...'})
+
+
+
+
+
+ {/* Latest Block (Static Example) */}
+
+
{t.latestBlock}
+
133182496 (2.00s)
+
+
+ {/* Transactions (Static Example) */}
+
+
{t.transactions}
+
498.28 M (10.5 TPS)
+
+
+ {/* OP Price */}
+
+
+ OP
+
+
+
OP PRICE
+
+ ${opPrice || 'Loading...'}
+
+ ({opChange || '...'})
+
+
+
+
+
+ {/* L1 TXN BATCH (Static Example) */}
+
+
{t.l1TxnBatch}
+
1.17 M
+
+
+ {/* L1 STATE BATCH (Static Example) */}
+
+
{t.l1StateBatch}
+
8878
+
+
+
+ {/* Transaction History Graph (Static SVG Example) */}
+
+
{t.transactionHistory}
+
+
+
+
+ );
+};
+
+export default CryptoPathExplorer;
diff --git a/components/home/EthPriceLine.tsx b/components/home/EthPriceLine.tsx
new file mode 100644
index 0000000..66fd865
--- /dev/null
+++ b/components/home/EthPriceLine.tsx
@@ -0,0 +1,141 @@
+import React, { useEffect, useState, useRef } from 'react';
+
+const EthPriceLine = () => {
+ const [prices, setPrices] = useState({
+ eth: { price: 0, change: 0 },
+ btc: { price: 0, change: 0 },
+ ltc: { price: 0, change: 0 },
+ xrp: { price: 0, change: 0 },
+ ada: { price: 0, change: 0 },
+ doge: { price: 0, change: 0 },
+ sol: { price: 0, change: 0 },
+ dot: { price: 0, change: 0 },
+ bnb: { price: 0, change: 0 },
+ // Add more coins as needed
+ });
+
+ const scrollRef = useRef(null);
+ const contentRef = useRef(null);
+
+ useEffect(() => {
+ const fetchPrices = async () => {
+ try {
+ const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=ethereum,bitcoin,litecoin,ripple,cardano,dogecoin,solana,polkadot,binancecoin&vs_currencies=usd&include_24hr_change=true');
+ const data = await response.json();
+ setPrices({
+ eth: { price: data.ethereum.usd, change: data.ethereum.usd_24h_change },
+ btc: { price: data.bitcoin.usd, change: data.bitcoin.usd_24h_change },
+ ltc: { price: data.litecoin.usd, change: data.litecoin.usd_24h_change },
+ xrp: { price: data.ripple.usd, change: data.ripple.usd_24h_change },
+ ada: { price: data.cardano.usd, change: data.cardano.usd_24h_change },
+ doge: { price: data.dogecoin.usd, change: data.dogecoin.usd_24h_change },
+ sol: { price: data.solana.usd, change: data.solana.usd_24h_change },
+ dot: { price: data.polkadot.usd, change: data.polkadot.usd_24h_change },
+ bnb: { price: data.binancecoin.usd, change: data.binancecoin.usd_24h_change },
+ // Add more coins as needed
+ });
+ } catch (error) {
+ console.error('Error fetching prices:', error);
+ }
+ };
+
+ // Initial fetch
+ fetchPrices();
+
+ // Set up interval to fetch prices continuously - every 60 seconds
+ const intervalId = setInterval(fetchPrices, 60000);
+
+ // Clean up the interval when the component unmounts
+ return () => clearInterval(intervalId);
+ }, []);
+
+ // Set up the scrolling animation
+ useEffect(() => {
+ if (!scrollRef.current || !contentRef.current) return;
+
+ const scrollContainer = scrollRef.current;
+ const content = contentRef.current;
+ const scrollWidth = content.offsetWidth;
+ let scrollPosition = 0;
+ const scrollSpeed = 0.5; // Adjust for faster/slower scrolling
+
+ const scroll = () => {
+ scrollPosition += scrollSpeed;
+
+ // When the first item is completely scrolled out, move it to the end
+ if (scrollPosition >= scrollWidth) {
+ scrollPosition = 0;
+ }
+
+ scrollContainer.scrollLeft = scrollPosition;
+ requestAnimationFrame(scroll);
+ };
+
+ const animation = requestAnimationFrame(scroll);
+
+ return () => cancelAnimationFrame(animation);
+ }, []);
+
+ const formatChange = (change: number) => {
+ const isPositive = change >= 0;
+ return (
+
+ {isPositive ? '+' : ''}{change.toFixed(2)}%
+
+ );
+ };
+
+ const priceItem = (symbol: string, name: string, price: number, change: number) => (
+
+ {symbol} Price: ${price.toFixed(2)} ({formatChange(change)})
+ |
+
+ );
+
+ return (
+
+
+
+ {/* First set of items */}
+ {priceItem('ETH', 'Ethereum', prices.eth.price, prices.eth.change)}
+ {priceItem('BTC', 'Bitcoin', prices.btc.price, prices.btc.change)}
+ {priceItem('LTC', 'Litecoin', prices.ltc.price, prices.ltc.change)}
+ {priceItem('XRP', 'Ripple', prices.xrp.price, prices.xrp.change)}
+ {priceItem('ADA', 'Cardano', prices.ada.price, prices.ada.change)}
+ {priceItem('DOGE', 'Dogecoin', prices.doge.price, prices.doge.change)}
+ {priceItem('SOL', 'Solana', prices.sol.price, prices.sol.change)}
+ {priceItem('DOT', 'Polkadot', prices.dot.price, prices.dot.change)}
+ {priceItem('BNB', 'Binance Coin', prices.bnb.price, prices.bnb.change)}
+
+ {/* Duplicate for seamless scrolling */}
+ {priceItem('ETH', 'Ethereum', prices.eth.price, prices.eth.change)}
+ {priceItem('BTC', 'Bitcoin', prices.btc.price, prices.btc.change)}
+ {priceItem('LTC', 'Litecoin', prices.ltc.price, prices.ltc.change)}
+ {priceItem('XRP', 'Ripple', prices.xrp.price, prices.xrp.change)}
+ {priceItem('ADA', 'Cardano', prices.ada.price, prices.ada.change)}
+ {priceItem('DOGE', 'Dogecoin', prices.doge.price, prices.doge.change)}
+ {priceItem('SOL', 'Solana', prices.sol.price, prices.sol.change)}
+ {priceItem('DOT', 'Polkadot', prices.dot.price, prices.dot.change)}
+ {priceItem('BNB', 'Binance Coin', prices.bnb.price, prices.bnb.change)}
+
+
+
+ );
+};
+
+export default EthPriceLine;
\ No newline at end of file
diff --git a/components/home/TrendingNFTs.tsx b/components/home/TrendingNFTs.tsx
new file mode 100644
index 0000000..d5b2279
--- /dev/null
+++ b/components/home/TrendingNFTs.tsx
@@ -0,0 +1,140 @@
+import React, { useEffect, useState } from "react";
+
+// Define the interface for the API response
+interface TrendingNFTData {
+ nfts: {
+ success: boolean;
+ range: string;
+ sort: string;
+ order: string;
+ chain: string;
+ results: any[];
+ page: number;
+ pageCount: number;
+ resultCount: number;
+ resultsPerPage: number;
+ };
+}
+
+const TrendingNFTCollections: React.FC = () => {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Fetch NFT data on component mount
+ useEffect(() => {
+ let isMounted = true;
+ fetch("/api/dappradar-trending-nft") // Update this to your actual endpoint
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(`API request failed with status ${res.status}`);
+ }
+ return res.json();
+ })
+ .then((json: TrendingNFTData) => {
+ // Debug: log the JSON response
+ console.log("Fetched NFT Data:", json);
+ if (isMounted) {
+ setData(json);
+ setIsLoading(false);
+ }
+ })
+ .catch((err) => {
+ if (isMounted) {
+ setError(err.message);
+ setIsLoading(false);
+ }
+ });
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ // Loading state
+ if (isLoading) {
+ return (
+
+
Loading NFT collections...
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
Error loading NFT collections
+
{error}
+
+ );
+ }
+
+ // Ensure that data and data.nfts.results exist
+ const results = data?.nfts?.results || [];
+
+ if (results.length === 0) {
+ return (
+
+
No NFT collections available
+
+ );
+ }
+
+ // Render the NFT collection cards by mapping over data.nfts.results
+ return (
+
+
Trending NFT Collections
+
+ Explore the latest trending NFT collections based on sales volume and activity.
+
+
+ {results.map((item, index) => (
+
+
+
{
+ (e.currentTarget as HTMLImageElement).src = "https://placekitten.com/100/100";
+ }}
+ />
+
+
{item.name || "Unknown"}
+
+ {item.chains?.[0]?.[0] || "N/A"}
+
+
+
+
+
+ Floor price
+
+ {item.floorPrice ? `$${item.floorPrice}` : "N/A"}
+
+
+
+
+ Trading volume
+
+ {item.volume ? `$${item.volume}` : "N/A"}
+
+
+
+
+ No. of traders
+
+ {item.traders ? item.traders.toLocaleString() : "N/A"}
+
+
+
+ ))}
+
+
+ );
+};
+
+export default TrendingNFTCollections;
diff --git a/components/home/TrendingProjects.tsx b/components/home/TrendingProjects.tsx
new file mode 100644
index 0000000..e63b1c5
--- /dev/null
+++ b/components/home/TrendingProjects.tsx
@@ -0,0 +1,269 @@
+// TrendingProjects.tsx
+import React, { useEffect, useState } from 'react';
+
+interface TrendingData {
+ dapps: { results: any[] };
+ games: { results: any[] };
+ marketplaces: { results: any[] };
+}
+
+const TrendingProjects = () => {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+ fetch(`/api/dappradar-trending-project?chain=ethereum`)
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(`API request failed with status ${res.status}`);
+ }
+ return res.json();
+ })
+ .then((json: TrendingData) => {
+ if (isMounted) {
+ setData(json);
+ setIsLoading(false);
+ }
+ })
+ .catch((err) => {
+ if (isMounted) {
+ setError(err.message);
+ setIsLoading(false);
+ }
+ });
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ if (isLoading) {
+ return (
+
+
Loading trending projects...
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+
No data available
+ {error &&
{error}
}
+
+ );
+ }
+
+ return (
+
+
Trending Projects
+
Explore the leading decentralized applications, innovative crypto games, and bustling marketplaces. Stay updated with key performance metrics and uncover the next big opportunity in the blockchain ecosystem.
+ {error && (
+
+ Note: Displaying sample data. {error}
+
+ )}
+
+ {/*
+ Use a 3-column grid for the layout.
+ Each column has a title, subtitle, and a list of 3 items.
+ */}
+
+ {/* COLUMN 1: Top UAW */}
+
+ {/* Header Row: Title + Subtitle */}
+
+
Top UAW
+ % UAW (24h)
+
+
+ {/* List of Items */}
+
+ {data.dapps.results.slice(0, 3).map((item, index) => {
+ const changeValue = item.metrics?.uawPercentageChange;
+ const uawValue = item.metrics?.uaw;
+
+ return (
+
+ {/* Left side: Icon + Name */}
+
+
{
+ const target = e.target as HTMLImageElement;
+ target.onerror = null;
+ target.src = 'https://placekitten.com/100/100';
+ }}
+ />
+
+
+ {item.name || 'Unknown'}
+
+ +{item.chains?.length || 1}
+
+
+
+ {/* Right side: Percentage + UAW */}
+
+
0 ? 'text-green-400' : 'text-red-400'
+ }
+ >
+ {changeValue !== undefined
+ ? `${changeValue > 0 ? '+' : ''}${changeValue.toFixed(
+ 1
+ )}%`
+ : 'N/A'}
+
+
+ {uawValue
+ ? `${Number(uawValue).toLocaleString()} UAW`
+ : 'N/A'}
+
+
+
+ );
+ })}
+
+
+
+ {/* COLUMN 2: Top Games */}
+
+ {/* Header Row: Title + Subtitle */}
+
+
Top Games
+ % Balance (24h)
+
+
+ {/* List of Items */}
+
+ {data.games.results.slice(0, 3).map((item, index) => {
+ const changeValue = item.metrics?.balancePercentageChange;
+ const balanceValue = item.metrics?.balance;
+
+ return (
+
+ {/* Left side: Icon + Name */}
+
+
{
+ const target = e.target as HTMLImageElement;
+ target.onerror = null;
+ target.src = 'https://placekitten.com/100/100';
+ }}
+ />
+
+
+ {item.name || 'Unknown'}
+
+ +{item.chains?.length || 1}
+
+
+
+ {/* Right side: Percentage + Balance */}
+
+
0 ? 'text-green-400' : 'text-red-400'
+ }
+ >
+ {changeValue !== undefined
+ ? `${changeValue > 0 ? '+' : ''}${changeValue.toFixed(
+ 1
+ )}%`
+ : 'N/A'}
+
+
+ {balanceValue
+ ? `$${Number(balanceValue).toLocaleString()}`
+ : 'N/A'}
+
+
+
+ );
+ })}
+
+
+
+ {/* COLUMN 3: Top Marketplaces */}
+
+ {/* Header Row: Title + Subtitle */}
+
+
Top Marketplaces
+ Txns (24h)
+
+
+ {/* List of Items */}
+
+ {data.marketplaces.results.slice(0, 3).map((item, index) => {
+ const changeValue = item.metrics?.transactionsPercentageChange;
+ const txnsValue = item.metrics?.transactions;
+
+ return (
+
+ {/* Left side: Icon + Name */}
+
+
{
+ const target = e.target as HTMLImageElement;
+ target.onerror = null;
+ target.src = 'https://placekitten.com/100/100';
+ }}
+ />
+
+
+ {item.name || 'Unknown'}
+
+ +{item.chains?.length || 1}
+
+
+
+ {/* Right side: Percentage + Txns */}
+
+
0 ? 'text-green-400' : 'text-red-400'
+ }
+ >
+ {changeValue !== undefined
+ ? `${changeValue > 0 ? '+' : ''}${changeValue.toFixed(
+ 1
+ )}%`
+ : 'N/A'}
+
+
+ {txnsValue
+ ? `${Number(txnsValue).toLocaleString()} Transactions`
+ : 'N/A'}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+};
+
+export default TrendingProjects;
diff --git a/components/icons/Neo4jIcon.tsx b/components/icons/Neo4jIcon.tsx
new file mode 100644
index 0000000..4e2bfc6
--- /dev/null
+++ b/components/icons/Neo4jIcon.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+
+interface Neo4jIconProps {
+ className?: string;
+ size?: number;
+}
+
+export const Neo4jIcon: React.FC = ({
+ className = "",
+ size = 18
+}) => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default Neo4jIcon;
diff --git a/components/SearchBar.tsx b/components/search-offchain/SearchBarOffChain.tsx
similarity index 70%
rename from components/SearchBar.tsx
rename to components/search-offchain/SearchBarOffChain.tsx
index 34b9f83..65b9b18 100644
--- a/components/SearchBar.tsx
+++ b/components/search-offchain/SearchBarOffChain.tsx
@@ -4,13 +4,14 @@ import { useState } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
-import { Search, X } from "lucide-react"
-import { LoadingScreen } from "@/components/loading-screen";
+import { Search,X } from "lucide-react"
+import { LoadingScreen } from "@/components/loading-screen"
-export default function SearchBar() {
+export default function SearchBarOffChain() {
const [address, setAddress] = useState("")
- const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
+ const [isLoading, setIsLoading] = useState(false)
+ const [searchType, setSearchType] = useState<"onchain" | "offchain">("offchain");
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
@@ -21,14 +22,18 @@ export default function SearchBar() {
try {
// Giả lập thời gian tải (có thể thay bằng API call thực tế)
await new Promise(resolve => setTimeout(resolve, 2500));
- router.push(`/search/?address=${encodeURIComponent(address)}`);
+ if (searchType === "onchain") {
+ router.push(`/search/?address=${encodeURIComponent(address)}`);
+ } else {
+ router.push(`/search-offchain/?address=${encodeURIComponent(address)}`);
+ }
} catch (error) {
console.error("Search error:", error);
} finally {
setIsLoading(false);
}
}
-
+
const clearAddress = () => {
setAddress("")
}
@@ -61,7 +66,7 @@ export default function SearchBar() {
)}
-
+
Search
+
setSearchType(e.target.value as "onchain" | "offchain")}
+ className="ml-2 px-2 py-1 h-9 text-sm text-white bg-black border border-gray-700 rounded-md focus:outline-none hover:bg-gray-800 transition-colors"
+ >
+ On-Chain
+ Off-Chain
+
-
>
)
-}
\ No newline at end of file
+}
+
diff --git a/components/TransactionGraphOffChain.tsx b/components/search-offchain/TransactionGraphOffChain.tsx
similarity index 100%
rename from components/TransactionGraphOffChain.tsx
rename to components/search-offchain/TransactionGraphOffChain.tsx
diff --git a/components/TransactionTableOffChain.tsx b/components/search-offchain/TransactionTableOffChain.tsx
similarity index 100%
rename from components/TransactionTableOffChain.tsx
rename to components/search-offchain/TransactionTableOffChain.tsx
diff --git a/components/search/AddressErrorCard.tsx b/components/search/AddressErrorCard.tsx
new file mode 100644
index 0000000..5dd7e8c
--- /dev/null
+++ b/components/search/AddressErrorCard.tsx
@@ -0,0 +1,136 @@
+"use client"
+
+import React from "react"
+import { Card, CardContent } from "@/components/ui/card"
+import { AlertTriangle, Copy, ArrowRight, HelpCircle, Edit } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { motion } from "framer-motion"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+
+interface AddressErrorCardProps {
+ address: string
+ errorMessage: string
+}
+
+export default function AddressErrorCard({ address, errorMessage }: AddressErrorCardProps) {
+ const router = useRouter()
+
+ const exampleAddresses = [
+ "0x690B9A9E9aa1C9dB991C7721a92d351Db4FaC990", // Coinbase address
+ "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH contract
+ "0xdAC17F958D2ee523a2206206994597C13D831ec7", // USDT contract
+ ]
+
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text)
+ toast.success("Address copied to clipboard")
+ }
+
+ const navigateToExample = (address: string) => {
+ router.push(`/search/?address=${address}&network=mainnet&provider=infura`)
+ }
+
+ const changeAddress = () => {
+ window.scrollTo({ top: 0, behavior: 'smooth' })
+
+ // Focus on the search input after a small delay
+ setTimeout(() => {
+ const searchInput = document.querySelector('input[type="text"]') as HTMLInputElement
+ if (searchInput) {
+ searchInput.focus()
+ }
+ }, 500)
+ }
+
+ return (
+
+
+
+
+
+
+
+
Invalid Ethereum Address
+
{errorMessage}
+
+
+
+
+
Entered Address:
+
+ {address}
+ copyToClipboard(address)}
+ className="shrink-0"
+ >
+
+
+
+
+
+
+
+
+
+ How to fix this?
+
+
+ Ethereum addresses are hexadecimal and 42 characters long (including '0x')
+ Check for typos or missing characters
+ Ensure you copied the entire address from its source
+ Try again with a valid Ethereum address
+
+
+
+ Change Address
+
+
+
+
+
Try these example addresses:
+
+ {exampleAddresses.map((addr) => (
+
+
{addr}
+
+
copyToClipboard(addr)}
+ className="h-8 px-2 text-gray-400 hover:text-white"
+ >
+ Copy
+
+
navigateToExample(addr)}
+ className="h-8 px-2 bg-gradient-to-r from-amber-600 to-amber-700"
+ >
+ View
+
+
+
+ ))}
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/search/NFTGallery.tsx b/components/search/NFTGallery.tsx
new file mode 100644
index 0000000..e27c744
--- /dev/null
+++ b/components/search/NFTGallery.tsx
@@ -0,0 +1,503 @@
+"use client"
+
+import { useSearchParams } from "next/navigation"
+import { useEffect, useState } from "react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Loader2, Copy, Check, ExternalLink, AlertTriangle, EyeOff } from "lucide-react"
+import Image from "next/image"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+import { Badge } from "@/components/ui/badge"
+import { Switch } from "@/components/ui/switch"
+import { Label } from "@/components/ui/label"
+import { cn } from "@/lib/utils"
+import { handleImageError, getDevImageProps } from "@/utils/imageUtils"
+
+interface NFT {
+ tokenID: string
+ tokenName: string
+ tokenSymbol: string
+ contractAddress: string
+ imageUrl?: string
+}
+
+interface NFTResponse {
+ nfts: NFT[]
+ totalCount: number
+ pageKey?: string | null
+ error?: string
+}
+
+// Utility to truncate long strings
+const truncateString = (str: string, startChars = 6, endChars = 4) => {
+ if (!str) return "";
+ if (str.length <= startChars + endChars) return str;
+ return `${str.slice(0, startChars)}...${str.slice(-endChars)}`;
+};
+
+// Enhanced utility to handle and format token IDs correctly
+const formatTokenId = (tokenId: string) => {
+ if (!tokenId) return "Unknown ID";
+
+ // Remove leading zeros for display if the ID is in hex format
+ if (tokenId.startsWith('0x')) {
+ // Convert to BigInt to handle large numbers correctly
+ try {
+ const bigIntValue = BigInt(tokenId);
+ // If the value is actually zero, return 0x0
+ if (bigIntValue === BigInt(0)) return "0x0";
+ // Otherwise return the hex representation without extra leading zeros
+ return '0x' + bigIntValue.toString(16);
+ } catch (e) {
+ // If conversion fails, return the original
+ return tokenId;
+ }
+ }
+
+ return tokenId;
+};
+
+// Detect potential spam NFTs
+const isPotentialSpam = (nft: NFT): boolean => {
+ // Common patterns in spam NFT names
+ const spamPatterns = [
+ /visit.*website/i,
+ /claim.*rewards/i,
+ /.+\.org/i,
+ /.+\.net/i,
+ /airdrop/i,
+ /^get.+/i,
+ /free/i
+ ];
+
+ // Check name against spam patterns
+ if (nft.tokenName && spamPatterns.some(pattern => pattern.test(nft.tokenName))) {
+ return true;
+ }
+
+ // Check if token ID is suspiciously simple like "0x00"
+ if (nft.tokenID === "0x00" || nft.tokenID === "0x0" || nft.tokenID === "0") {
+ return true;
+ }
+
+ return false;
+};
+
+export default function NFTGallery() {
+ const searchParams = useSearchParams()
+ const address = searchParams.get("address")
+ const [nfts, setNFTs] = useState
([])
+ const [totalCount, setTotalCount] = useState(0)
+ const [pageKeys, setPageKeys] = useState<(string | null)[]>([null])
+ const [currentPage, setCurrentPage] = useState(1)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [imageErrors, setImageErrors] = useState>({})
+ const nftsPerPage = 15 // Changed from 20 to 15 NFTs per page
+
+ // Modal state
+ const [selectedNft, setSelectedNft] = useState(null)
+ const [isModalOpen, setIsModalOpen] = useState(false)
+
+ // Copy feedback state
+ const [copiedText, setCopiedText] = useState(null)
+
+ // Copy to clipboard function with visual feedback
+ const copyToClipboard = (text: string, identifier: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopiedText(identifier);
+ setTimeout(() => {
+ setCopiedText(null);
+ }, 2000);
+ });
+ };
+
+ const fetchNFTs = async (page: number, pageKey?: string | null) => {
+ if (!address) return
+
+ setLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`/api/nfts?address=${address}&limit=${nftsPerPage}${pageKey ? `&pageKey=${pageKey}` : ''}`)
+ const data: NFTResponse = await response.json()
+
+ if (data.error) {
+ throw new Error(data.error)
+ }
+
+ setNFTs(data.nfts)
+ setTotalCount(data.totalCount)
+
+ setPageKeys(prev => {
+ const newPageKeys = [...prev]
+ newPageKeys[page] = data.pageKey || null
+ return newPageKeys
+ })
+ } catch (err) {
+ console.error("Error fetching NFTs:", err)
+ setError(err instanceof Error ? err.message : "Failed to fetch NFTs")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ setCurrentPage(1)
+ setPageKeys([null])
+ fetchNFTs(1, null)
+ }, [address])
+
+ const markImageAsError = (nftId: string) => {
+ setImageErrors(prev => ({
+ ...prev,
+ [nftId]: true
+ }))
+ }
+
+ const totalPages = Math.ceil(totalCount / nftsPerPage)
+
+ const handlePreviousPage = () => {
+ if (currentPage > 1) {
+ const newPage = currentPage - 1
+ setCurrentPage(newPage)
+ fetchNFTs(newPage, pageKeys[newPage - 1])
+ }
+ }
+
+ const handleNextPage = () => {
+ if (currentPage < totalPages && pageKeys[currentPage]) {
+ const newPage = currentPage + 1
+ setCurrentPage(newPage)
+ fetchNFTs(newPage, pageKeys[currentPage])
+ }
+ }
+
+ // Handler for opening the detail modal
+ const openNftDetails = (nft: NFT) => {
+ setSelectedNft(nft);
+ setIsModalOpen(true);
+ };
+
+ // Add filter for spam NFTs
+ const [hideSpamNFTs, setHideSpamNFTs] = useState(true);
+ const [filteredNFTs, setFilteredNFTs] = useState([]);
+
+ useEffect(() => {
+ // Filter NFTs when the spam filter changes or when nfts array changes
+ if (hideSpamNFTs) {
+ setFilteredNFTs(nfts.filter(nft => !isPotentialSpam(nft)));
+ } else {
+ setFilteredNFTs(nfts);
+ }
+ }, [nfts, hideSpamNFTs]);
+
+ if (!address) {
+ return null
+ }
+
+ if (loading) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+ Error: {error}
+
+
+ )
+ }
+
+ if (nfts.length === 0) {
+ return (
+
+
+ NFT Gallery
+
+
+ No NFTs found for this address.
+
+
+ )
+ }
+
+ return (
+ <>
+
+
+ NFT Gallery
+
+ Hide Potential Spam
+
+
+
+
+ {hideSpamNFTs && nfts.length > filteredNFTs.length && (
+
+
+
+ {nfts.length - filteredNFTs.length} potential spam NFTs are hidden.
+ setHideSpamNFTs(false)}>
+ Show all
+
+
+
+ )}
+
+
+ {filteredNFTs.map((nft) => {
+ const nftId = `${nft.contractAddress}-${nft.tokenID}`;
+ const formattedTokenId = formatTokenId(nft.tokenID);
+ const truncatedTokenId = truncateString(formattedTokenId, 6, 4);
+ const isSpam = isPotentialSpam(nft);
+
+ return (
+
openNftDetails(nft)}
+ >
+
+
+ {imageErrors[nftId] || !nft.imageUrl ? (
+
+
+
{nft.tokenName || "NFT"}
+
#{truncatedTokenId}
+
+
+ ) : (
+
{
+ markImageAsError(nftId);
+ handleImageError(event);
+ }}
+ {...getDevImageProps()} // Add development mode props
+ />
+ )}
+
+
+ {isSpam && !hideSpamNFTs && (
+
+ Potential Spam
+
+ )}
+
+
+
+
{nft.tokenName || "Unnamed NFT"}
+
+
+
#{truncatedTokenId}
+
+
+ e.stopPropagation()}>
+ {
+ e.stopPropagation();
+ copyToClipboard(nft.tokenID, `tokenId-${nft.tokenID}`);
+ }}
+ >
+ {copiedText === `tokenId-${nft.tokenID}` ?
+ :
+
+ }
+
+
+
+ {copiedText === `tokenId-${nft.tokenID}` ? "Copied!" : "Copy token ID"}
+
+
+
+
+
+
+
+ )
+ })}
+
+
+ {filteredNFTs.length === 0 && (
+
+ {hideSpamNFTs && nfts.length > 0 ? (
+
+
+
All NFTs were filtered as potential spam
+
setHideSpamNFTs(false)}
+ >
+ Show All NFTs
+
+
+ ) : (
+
No NFTs found for this address.
+ )}
+
+ )}
+
+ {totalPages > 1 && (
+
+
+ Previous
+
+
+ Page {currentPage} of {totalPages}
+
+
+ Next
+
+
+ )}
+
+
+
+ {/* NFT Details Modal */}
+
+
+ {selectedNft && (
+ <>
+
+
+ {selectedNft.tokenName || "Unnamed NFT"}
+ {isPotentialSpam(selectedNft) && (
+ Potential Spam
+ )}
+
+
+ {selectedNft.tokenSymbol && (
+ {selectedNft.tokenSymbol}
+ )}
+
+
+
+ {isPotentialSpam(selectedNft) && (
+
+
+
+
+ This NFT contains patterns commonly associated with spam or phishing attempts.
+ Be cautious with any links or claims for rewards.
+
+
+
+ )}
+
+
+
+ {selectedNft.imageUrl && !imageErrors[`${selectedNft.contractAddress}-${selectedNft.tokenID}`] ? (
+
{
+ markImageAsError(`${selectedNft.contractAddress}-${selectedNft.tokenID}`);
+ handleImageError(event);
+ }}
+ {...getDevImageProps()} // Add development mode props
+ />
+ ) : (
+
+
+
{selectedNft.tokenName || "NFT"}
+
#{formatTokenId(selectedNft.tokenID)}
+
+
+ )}
+
+
+
+
+
Token ID
+
+
+ {formatTokenId(selectedNft.tokenID)}
+
+
copyToClipboard(selectedNft.tokenID, `modal-tokenId`)}
+ >
+ {copiedText === `modal-tokenId` ?
+ :
+
+ }
+
+
+
+
+
+
Contract Address
+
+
+ {selectedNft.contractAddress}
+
+
copyToClipboard(selectedNft.contractAddress, `modal-contract`)}
+ >
+ {copiedText === `modal-contract` ?
+ :
+
+ }
+
+
+
+
+
+ window.open(`https://etherscan.io/token/${selectedNft.contractAddress}?a=${selectedNft.tokenID}`, '_blank')}
+ >
+
+ View on Etherscan
+
+
+
+
+ >
+ )}
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/components/search/Portfolio.tsx b/components/search/Portfolio.tsx
new file mode 100644
index 0000000..b336a84
--- /dev/null
+++ b/components/search/Portfolio.tsx
@@ -0,0 +1,455 @@
+"use client"
+
+import { useSearchParams } from "next/navigation"
+import { useEffect, useState } from "react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Loader2, Coins, ArrowUpDown, ArrowLeft, ArrowRight, AlertCircle } from "lucide-react"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { Switch } from "@/components/ui/switch"
+import { Button } from "@/components/ui/button"
+import { toast } from "sonner"
+import Image from "next/image"
+import { handleImageError, getDevImageProps } from "@/utils/imageUtils"
+
+interface TokenBalance {
+ token?: string
+ contractAddress?: string
+ token_address?: string
+ name?: string
+ symbol?: string
+ balance?: string
+ balanceFormatted?: string
+ decimals?: number
+ usdPrice?: number
+ usdValue?: number
+ chain?: string
+ chainName?: string
+ chainIcon?: string
+ logo?: string
+ thumbnail?: string
+}
+
+export default function Portfolio() {
+ const searchParams = useSearchParams()
+ const address = searchParams.get("address")
+ const [portfolio, setPortfolio] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [totalValue, setTotalValue] = useState(0)
+ const [provider, setProvider] = useState<"moralis" | "alchemy" | "combined">("moralis")
+ const [sortField, setSortField] = useState<"value" | "name" | "balance" | "chain">("value")
+ const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc")
+ const [filterChain, setFilterChain] = useState(null)
+ const [showZeroBalances, setShowZeroBalances] = useState(false)
+
+ // Pagination state
+ const [currentPage, setCurrentPage] = useState(1)
+ const tokensPerPage = 5 // Number of tokens to display per page
+
+ useEffect(() => {
+ if (address) {
+ fetchPortfolio(address);
+ }
+ }, [address, provider, showZeroBalances]);
+
+ const fetchPortfolio = async (walletAddress: string) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/portfolio?address=${walletAddress}&provider=${provider}`);
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || "Failed to fetch portfolio data");
+ }
+
+ const data = await response.json();
+
+ // Filter out zero balances if toggle is off
+ const filteredTokens = showZeroBalances
+ ? data.tokens
+ : data.tokens.filter((token: TokenBalance) =>
+ parseFloat(token.balance || token.balanceFormatted || '0') > 0
+ );
+
+ setPortfolio(filteredTokens);
+ setTotalValue(data.totalValue);
+ } catch (error) {
+ console.error("Error fetching portfolio:", error);
+ setError(error instanceof Error ? error.message : "Failed to fetch portfolio data");
+ toast.error("Failed to fetch portfolio data");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSort = (field: "value" | "name" | "balance" | "chain") => {
+ if (sortField === field) {
+ setSortDirection(sortDirection === "asc" ? "desc" : "asc");
+ } else {
+ setSortField(field);
+ setSortDirection("desc");
+ }
+ };
+
+ const sortedPortfolio = [...portfolio].sort((a, b) => {
+ let comparison = 0;
+
+ switch (sortField) {
+ case "value":
+ comparison = (a.usdValue || 0) - (b.usdValue || 0);
+ break;
+ case "name":
+ comparison = (a.name || "").localeCompare(b.name || "");
+ break;
+ case "balance":
+ const aBalance = parseFloat(a.balance || a.balanceFormatted || "0");
+ const bBalance = parseFloat(b.balance || b.balanceFormatted || "0");
+ comparison = aBalance - bBalance;
+ break;
+ case "chain":
+ comparison = (a.chainName || "").localeCompare(b.chainName || "");
+ break;
+ }
+
+ return sortDirection === "asc" ? comparison : -comparison;
+ });
+
+ const filteredPortfolio = filterChain
+ ? sortedPortfolio.filter(token => token.chain === filterChain)
+ : sortedPortfolio;
+
+ // Extract unique chains for the filter dropdown
+ const uniqueChains = Array.from(new Set(portfolio.map(token => token.chain)))
+ .filter(Boolean) as string[];
+
+ // Calculate pagination
+ const totalPages = Math.ceil(filteredPortfolio.length / tokensPerPage)
+ const paginatedPortfolio = filteredPortfolio.slice(
+ (currentPage - 1) * tokensPerPage,
+ currentPage * tokensPerPage
+ )
+
+ const nextPage = () => {
+ if (currentPage < totalPages) {
+ setCurrentPage(currentPage + 1)
+ }
+ }
+
+ const prevPage = () => {
+ if (currentPage > 1) {
+ setCurrentPage(currentPage - 1)
+ }
+ }
+
+ const clearFilter = () => {
+ setFilterChain(null);
+ };
+
+ const toggleShowZeroBalances = () => {
+ setShowZeroBalances(!showZeroBalances);
+ };
+
+ const formatTokenLogo = (token: TokenBalance) => {
+ if (token.logo || token.thumbnail) {
+ const imageUrl = token.logo || token.thumbnail || "/icons/token-placeholder.png";
+ return (
+
+ handleImageError(event, "/icons/token-placeholder.png")}
+ {...getDevImageProps()} // Add development mode props
+ />
+
+ );
+ }
+ return (
+
+ {token.symbol?.substring(0, 2) || "?"}
+
+ );
+ };
+
+ const formatChainLogo = (token: TokenBalance) => {
+ if (token.chainIcon) {
+ return (
+
+ handleImageError(event, "/icons/chain-placeholder.png")}
+ {...getDevImageProps()} // Add development mode props
+ />
+
+ );
+ }
+ return null;
+ };
+
+ // Loading state
+ if (loading) {
+ return (
+
+
+ Portfolio
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
+ Portfolio
+
+
+
+
+
Error loading portfolio
+
{error}
+
address && fetchPortfolio(address)}
+ >
+ Retry
+
+
+
+
+ );
+ }
+
+ // Empty state
+ if (portfolio.length === 0) {
+ return (
+
+
+ Portfolio
+
+
+
+ No tokens found for this address
+ Try switching providers or enabling zero balances
+
+
+ Provider:
+ setProvider(e.target.value as "moralis" | "alchemy" | "combined")}
+ className="bg-gray-800 border border-amber-500/30 rounded p-1 text-sm text-white"
+ >
+ Moralis
+ Alchemy
+ Combined
+
+
+
+ Show Zero Balances:
+
+
+
+
+
+ );
+ }
+
+ // Normal state with data
+ return (
+
+
+ Portfolio
+
+
+ Total: ${totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+
+
+ {portfolio.length} Tokens
+
+
+
+
+
+
+
+ Provider:
+ setProvider(e.target.value as "moralis" | "alchemy" | "combined")}
+ className="bg-gray-800 border border-amber-500/30 rounded p-1 text-sm text-gray-200"
+ >
+ Moralis
+ Alchemy
+ Combined
+
+
+
+ Show Zero Balances:
+
+
+
+
+
+ Filter Chain:
+ setFilterChain(e.target.value || null)}
+ className="bg-gray-800 border border-amber-500/30 rounded p-1 text-sm text-gray-200"
+ >
+ All Chains
+ {uniqueChains.map((chain) => (
+ {chain}
+ ))}
+
+ {filterChain && (
+
+ Clear
+
+ )}
+
+
+
+
+
+
+
+ Token
+ handleSort("balance")}>
+
+ Balance
+ {sortField === "balance" && (
+
+ )}
+
+
+ handleSort("value")}>
+
+ Value
+ {sortField === "value" && (
+
+ )}
+
+
+ handleSort("chain")}>
+
+ Chain
+ {sortField === "chain" && (
+
+ )}
+
+
+
+
+
+ {paginatedPortfolio.map((token, idx) => (
+
+
+
+ {formatTokenLogo(token)}
+
+
{token.name || 'Unknown Token'}
+
{token.symbol}
+
+
+
+
+
+
+
+
+ {parseFloat(token.balance || token.balanceFormatted || '0').toLocaleString(undefined, {
+ maximumFractionDigits: 6
+ })}
+
+
+
+ {parseFloat(token.balance || token.balanceFormatted || '0').toString()} {token.symbol}
+
+
+
+
+
+ ${(token.usdValue || 0).toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2
+ })}
+
+
+
+ {formatChainLogo(token)}
+ {token.chainName}
+
+
+
+ ))}
+
+
+
+
+ {/* Pagination controls */}
+ {totalPages > 1 && (
+
+
+ Previous
+
+
+
+ Page {currentPage} of {totalPages}
+
+
+
+ Next
+
+
+ )}
+
+
+ );
+}
diff --git a/components/search/SearchBar.tsx b/components/search/SearchBar.tsx
new file mode 100644
index 0000000..7009772
--- /dev/null
+++ b/components/search/SearchBar.tsx
@@ -0,0 +1,267 @@
+"use client"
+
+import { useState } from "react"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { useRouter } from "next/navigation"
+import { Search, X, Globe, AlertTriangle } from "lucide-react"
+import { LoadingScreen } from "@/components/loading-screen"
+import Neo4jIcon from "@/components/icons/Neo4jIcon"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
+import { Label } from "@/components/ui/label"
+import { toast } from "sonner"
+
+export type NetworkType = "mainnet" | "optimism" | "arbitrum"
+export type ProviderType = "etherscan" | "infura"
+
+// Ethereum address validation regex pattern
+const ETH_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/;
+
+export default function SearchBar() {
+ const [address, setAddress] = useState("")
+ const [isLoading, setIsLoading] = useState(false)
+ const [addressError, setAddressError] = useState(null)
+ const router = useRouter()
+
+ const [searchType, setSearchType] = useState<"onchain" | "offchain">("onchain")
+ const [network, setNetwork] = useState("mainnet")
+ const [provider, setProvider] = useState("etherscan")
+
+ // Validate Ethereum address
+ const validateAddress = (addr: string): boolean => {
+ if (!addr) return false;
+
+ // For on-chain searches, validate Ethereum address format
+ if (searchType === "onchain") {
+ if (!ETH_ADDRESS_REGEX.test(addr)) {
+ setAddressError("Invalid Ethereum address format. Must start with 0x followed by 40 hex characters.");
+ return false;
+ }
+ } else {
+ // For off-chain searches, validate Neo4j ID format
+ if (addr.length < 3) {
+ setAddressError("Neo4j identifier must be at least 3 characters");
+ return false;
+ }
+ }
+
+ setAddressError(null);
+ return true;
+ };
+
+ // Get available networks based on selected provider
+ const getAvailableNetworks = () => {
+ if (provider === "infura") {
+ return [
+ { value: "mainnet", label: "Ethereum Mainnet" },
+ { value: "optimism", label: "Optimism" },
+ { value: "arbitrum", label: "Arbitrum" },
+ ]
+ } else {
+ // Default Etherscan only supports Ethereum mainnet
+ return [
+ { value: "mainnet", label: "Ethereum Mainnet" },
+ ]
+ }
+ }
+
+ // When provider changes, reset network if it's not available in the new provider
+ const handleProviderChange = (newProvider: ProviderType) => {
+ setProvider(newProvider)
+
+ // Get available networks for the new provider
+ const availableNetworks = getAvailableNetworks().map(net => net.value)
+
+ // Check if current network is available in the new provider
+ if (!availableNetworks.includes(network)) {
+ // If not, set to the first available network
+ setNetwork(availableNetworks[0] as NetworkType)
+ }
+ }
+
+ const handleSearch = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!address.trim()) return
+
+ // Validate address before proceeding
+ if (!validateAddress(address.trim())) {
+ toast.error("Invalid address format", {
+ description: addressError || "Please check the address format and try again.",
+ action: {
+ label: 'Learn More',
+ onClick: () => window.open('https://ethereum.org/en/developers/docs/intro-to-ethereum/#ethereum-accounts', '_blank'),
+ }
+ });
+ return;
+ }
+
+ setIsLoading(true)
+
+ try {
+ // Simulate loading time
+ await new Promise(resolve => setTimeout(resolve, 1000))
+ if (searchType === "onchain") {
+ router.push(`/search/?address=${encodeURIComponent(address)}&network=${network}&provider=${provider}`)
+ } else {
+ router.push(`/search-offchain/?address=${encodeURIComponent(address)}`)
+ }
+ } catch (error) {
+ console.error("Search error:", error)
+ toast.error("An error occurred during search. Please try again.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const clearAddress = () => {
+ setAddress("")
+ setAddressError(null)
+ }
+
+ const handleAddressChange = (e: React.ChangeEvent) => {
+ setAddress(e.target.value);
+
+ // Clear error when user starts typing again
+ if (addressError) {
+ setAddressError(null);
+ }
+ };
+
+ const availableNetworks = getAvailableNetworks()
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/components/search/TransactionGraph.tsx b/components/search/TransactionGraph.tsx
new file mode 100644
index 0000000..4e154d6
--- /dev/null
+++ b/components/search/TransactionGraph.tsx
@@ -0,0 +1,494 @@
+"use client";
+
+import { useSearchParams, useRouter } from "next/navigation";
+import { useEffect, useState, useCallback, useRef } from "react";
+import dynamic from "next/dynamic";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Loader2, Clock, AlertTriangle } from "lucide-react";
+import { ErrorCard } from "@/components/ui/error-card";
+
+// Only apply timeout for non-Infura providers - Infura needs unlimited time
+const ETHERSCAN_TIMEOUT = 120000; // 30 seconds timeout for Etherscan
+
+// Dynamically import ForceGraph2D
+const ForceGraph2D = dynamic(() => import("react-force-graph-2d"), { ssr: false });
+
+interface Transaction {
+ id: string;
+ from: string;
+ to: string;
+ value: string;
+ timestamp: string;
+}
+
+// Define our node type with our custom properties.
+export interface GraphNode {
+ id: string;
+ label: string;
+ color: string;
+ type: string;
+ x?: number;
+ y?: number;
+ vx?: number;
+ vy?: number;
+ fx?: number;
+ fy?: number;
+}
+
+interface GraphData {
+ nodes: GraphNode[];
+ links: { source: string; target: string; value: number }[];
+}
+
+const getRandomColor = () => `#${Math.floor(Math.random() * 16777215).toString(16)}`;
+
+function shortenAddress(address: string): string {
+ return `${address.slice(0, 3)}...${address.slice(-2)}`;
+}
+
+// A mock function to get a name for an address (replace with your actual logic)
+function getNameForAddress(address: string): string | null {
+ const mockNames: { [key: string]: string } = {
+ "0x1234567890123456789012345678901234567890": "Alice",
+ "0x0987654321098765432109876543210987654321": "Bob",
+ };
+ return mockNames[address] || null;
+}
+
+export default function TransactionGraph() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const address = searchParams.get("address");
+ const network = searchParams.get("network") || "mainnet";
+ const provider = searchParams.get("provider") || "etherscan";
+ const [graphData, setGraphData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [errorType, setErrorType] = useState<"timeout" | "network" | "api" | "notFound" | "unknown">("unknown");
+ const abortControllerRef = useRef(null);
+ const [loadingProgress, setLoadingProgress] = useState(0);
+ const progressIntervalRef = useRef(null);
+ const [loadingTimeElapsed, setLoadingTimeElapsed] = useState(0);
+ const timeElapsedIntervalRef = useRef(null);
+
+ useEffect(() => {
+ if (address) {
+ setLoading(true);
+ setError(null);
+ setLoadingProgress(0);
+ setLoadingTimeElapsed(0);
+
+ // Create a new AbortController for this request
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+
+ // For Infura, we don't use any timeouts to prevent AbortErrors
+ abortControllerRef.current = new AbortController();
+ const signal = abortControllerRef.current.signal;
+
+ // Only set timeout for non-Infura providers
+ let timeoutId: NodeJS.Timeout | null = null;
+
+ if (provider !== 'infura') {
+ // Set up timeout only for non-Infura providers
+ timeoutId = setTimeout(() => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ setErrorType("timeout");
+ setError(`The request took too long to complete (${ETHERSCAN_TIMEOUT/1000}s). Please try switching to Infura.`);
+ setLoading(false);
+
+ // Clear intervals
+ if (progressIntervalRef.current) {
+ clearInterval(progressIntervalRef.current);
+ progressIntervalRef.current = null;
+ }
+
+ if (timeElapsedIntervalRef.current) {
+ clearInterval(timeElapsedIntervalRef.current);
+ timeElapsedIntervalRef.current = null;
+ }
+ }
+ }, ETHERSCAN_TIMEOUT);
+ }
+
+ // Set up a progress interval to show simulated loading progress
+ progressIntervalRef.current = setInterval(() => {
+ setLoadingProgress(prev => {
+ // Even slower progress for Infura - never make it look like it's almost done
+ const incrementRate = provider === 'infura' ? 0.01 : 0.05;
+ const maxProgress = provider === 'infura' ? 80 : 95; // Cap at 80% for Infura
+ const newProgress = prev + (maxProgress - prev) * incrementRate;
+ return Math.min(newProgress, maxProgress);
+ });
+ }, 1000);
+
+ // Set up time elapsed counter
+ timeElapsedIntervalRef.current = setInterval(() => {
+ setLoadingTimeElapsed(prev => prev + 1);
+ }, 1000);
+
+ // Get the base URL dynamically
+ const baseUrl = typeof window !== 'undefined'
+ ? window.location.origin
+ : process.env.NEXT_PUBLIC_URL || '';
+
+ console.log(`Fetching transactions for ${address} on ${network} using ${provider}...`);
+
+ fetch(`${baseUrl}/api/transactions?address=${address}&network=${network}&provider=${provider}&offset=50`, { signal })
+ .then((res) => {
+ if (!res.ok) {
+ if (res.status === 404) {
+ setErrorType("notFound");
+ throw new Error(`No transaction data found for this address on ${network}`);
+ } else {
+ setErrorType("api");
+ throw new Error(`API responded with status: ${res.status}`);
+ }
+ }
+ return res.json();
+ })
+ .then((data: unknown) => {
+ if (!Array.isArray(data)) {
+ setErrorType("api");
+ throw new Error((data as any).error || "Unexpected API response");
+ }
+
+ const transactions = data as Transaction[];
+
+ // Set loading progress to 100%
+ setLoadingProgress(100);
+
+ if (transactions.length === 0) {
+ // Create a simple graph with just the address
+ const singleNode = {
+ id: address,
+ label: shortenAddress(address),
+ color: "#f5b056", // Match theme color
+ type: "both",
+ };
+
+ setGraphData({
+ nodes: [singleNode],
+ links: [],
+ });
+
+ return;
+ }
+
+ // Special case for Bitcoin or other networks
+ const isBitcoinOrSpecial = network === 'bitcoin' ||
+ transactions.some(tx => tx.from === "Bitcoin Transaction" ||
+ !tx.from || !tx.to ||
+ tx.from === "Unknown");
+
+ if (isBitcoinOrSpecial) {
+ const nodes = new Map();
+ const links: GraphData["links"] = [];
+
+ // Create a simplified view with the address in the center
+ nodes.set(address, {
+ id: address,
+ label: shortenAddress(address),
+ color: network === 'bitcoin' ? "#f7931a" : "#f5b056", // Bitcoin orange or default
+ type: "both",
+ });
+
+ // Add transaction nodes around it
+ transactions.forEach((tx, index) => {
+ if (!tx.id) return; // Skip if no transaction ID
+
+ const txNodeId = `tx-${tx.id.substring(0, 8)}`;
+ nodes.set(txNodeId, {
+ id: txNodeId,
+ label: shortenAddress(tx.id),
+ color: getRandomColor(),
+ type: "transaction",
+ });
+
+ // Connect address to transaction
+ links.push({
+ source: address,
+ target: txNodeId,
+ value: 1,
+ });
+ });
+
+ setGraphData({
+ nodes: Array.from(nodes.values()),
+ links,
+ });
+
+ return;
+ }
+
+ // Regular EVM chain transaction graph
+ const nodes = new Map();
+ const links: GraphData["links"] = [];
+
+ transactions.forEach((tx) => {
+ // Skip transactions with invalid addresses
+ if (!tx.from || !tx.to || tx.from === "Bitcoin Transaction" ||
+ tx.from === "Unknown" || tx.to === "Unknown") {
+ return;
+ }
+
+ if (!nodes.has(tx.from)) {
+ const name = getNameForAddress(tx.from);
+ nodes.set(tx.from, {
+ id: tx.from,
+ label: name || shortenAddress(tx.from),
+ color: getRandomColor(),
+ type: tx.from === address ? "out" : "in",
+ });
+ }
+ if (!nodes.has(tx.to)) {
+ const name = getNameForAddress(tx.to);
+ nodes.set(tx.to, {
+ id: tx.to,
+ label: name || shortenAddress(tx.to),
+ color: getRandomColor(),
+ type: tx.to === address ? "in" : "out",
+ });
+ }
+
+ // Extract numeric value from the value string
+ let value = 1; // Default value
+ if (tx.value) {
+ const valueMatch = tx.value.match(/[\d.]+/);
+ if (valueMatch) {
+ value = parseFloat(valueMatch[0]);
+ }
+ }
+
+ links.push({
+ source: tx.from,
+ target: tx.to,
+ value: value || 1,
+ });
+ });
+
+ setGraphData({
+ nodes: Array.from(nodes.values()),
+ links,
+ });
+ })
+ .catch((err) => {
+ console.error("Error fetching transaction data for graph:", err);
+
+ if (err.name === 'AbortError') {
+ console.debug("Request aborted", { provider, network, timeElapsed: loadingTimeElapsed });
+
+ // Special handling for unexpected aborts - shouldn't happen with Infura
+ if (provider === 'infura') {
+ setErrorType("api");
+ setError("The request to Infura was unexpectedly terminated. Please try again.");
+ } else {
+ setErrorType("timeout");
+ setError(`Request timed out after ${loadingTimeElapsed} seconds. Try switching to Infura which can handle longer searches.`);
+ }
+ } else if (err.message.includes('fetch') || err.message.includes('network')) {
+ setErrorType("network");
+ setError("Network error. Please check your internet connection.");
+ } else if (err.message.includes('not found') || err.message.includes('No transaction data')) {
+ setErrorType("notFound");
+ setError(err.message || "No transactions found for this address");
+
+ // Create a fallback simple graph with just the address
+ const fallbackNode = {
+ id: address,
+ label: shortenAddress(address),
+ color: "#f5b056", // Match theme color
+ type: "both",
+ };
+
+ setGraphData({
+ nodes: [fallbackNode],
+ links: [],
+ });
+ } else {
+ setErrorType("api");
+ setError(err.message || "Failed to fetch transaction data for graph");
+ }
+ })
+ .finally(() => {
+ // Clear timeout if it exists
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId);
+ }
+
+ setLoading(false);
+
+ // Clear the intervals
+ if (progressIntervalRef.current) {
+ clearInterval(progressIntervalRef.current);
+ progressIntervalRef.current = null;
+ }
+
+ if (timeElapsedIntervalRef.current) {
+ clearInterval(timeElapsedIntervalRef.current);
+ timeElapsedIntervalRef.current = null;
+ }
+ });
+ }
+
+ return () => {
+ // Cleanup function - make sure to abort any in-progress requests when unmounting
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+
+ // Clear intervals
+ if (progressIntervalRef.current) {
+ clearInterval(progressIntervalRef.current);
+ }
+
+ if (timeElapsedIntervalRef.current) {
+ clearInterval(timeElapsedIntervalRef.current);
+ }
+ };
+ }, [address, network, provider]);
+
+ // Update onNodeClick to accept both the node and the MouseEvent.
+ const handleNodeClick = useCallback(
+ (node: { [others: string]: any }, event: MouseEvent) => {
+ const n = node as GraphNode;
+
+ // Skip click if this is a transaction node
+ if (n.id.startsWith('tx-')) {
+ return;
+ }
+
+ router.push(`/search/?address=${n.id}&network=${network}&provider=${provider}`);
+ },
+ [router, network, provider]
+ );
+
+ // Update nodes to reflect their transaction type ("both" if a node has both incoming and outgoing links)
+ useEffect(() => {
+ if (graphData) {
+ const updatedNodes: GraphNode[] = graphData.nodes.map((node) => {
+ // Skip transaction nodes
+ if (node.id.startsWith('tx-')) {
+ return node;
+ }
+
+ const incoming = graphData.links.filter(link => link.target === node.id);
+ const outgoing = graphData.links.filter(link => link.source === node.id);
+ if (incoming.length > 0 && outgoing.length > 0) {
+ // Explicitly assert that the type is the literal "both"
+ return { ...node, type: "both" as "both" };
+ }
+ return node;
+ });
+ if (JSON.stringify(updatedNodes) !== JSON.stringify(graphData.nodes)) {
+ // Use the existing graphData rather than a functional update.
+ setGraphData({
+ ...graphData,
+ nodes: updatedNodes,
+ });
+ }
+ }
+ }, [graphData]);
+
+ if (loading) {
+ return (
+
+
+
+
+ {provider === 'infura'
+ ? 'Loading transaction data from Infura...'
+ : 'Loading transaction data...'}
+
+
{Math.round(loadingProgress)}%
+
+
+
+ Time elapsed: {loadingTimeElapsed}s
+
+
+ {provider === 'infura' && (
+
+
+
+ Infura searches have no timeout and may take several minutes to complete for complex wallets. Please be patient.
+
+ {loadingTimeElapsed > 30 && (
+
+ Still searching... Infura queries can take a long time for addresses with many transactions.
+
+ )}
+
+ )}
+
+
+ );
+ }
+
+ if (error && !graphData) {
+ return (
+
+ );
+ }
+
+ if (!graphData) {
+ return null;
+ }
+
+ return (
+
+
+ Transaction Graph
+
+
+ node.id) as any}
+ nodeColor={((node: GraphNode) => node.color) as any}
+ nodeCanvasObject={
+ ((node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
+ if (node.x == null || node.y == null) return;
+ const { label, type, x, y } = node;
+ const fontSize = 4;
+ ctx.font = `${fontSize}px Sans-Serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.beginPath();
+ ctx.arc(x, y, type === "both" ? 4 : 3, 0, 2 * Math.PI, false);
+ ctx.fillStyle =
+ type === "in"
+ ? "rgba(0, 255, 0, 0.5)"
+ : type === "out"
+ ? "rgba(255, 0, 0, 0.5)"
+ : type === "transaction"
+ ? "rgba(245, 176, 86, 0.5)" // Transaction nodes
+ : "rgba(255, 255, 0, 0.5)";
+ ctx.fill();
+ ctx.fillStyle = "white";
+ ctx.fillText(label, x, y);
+ }) as any
+ }
+ nodeRelSize={6}
+ linkWidth={1}
+ linkColor={() => "rgb(255, 255, 255)"}
+ linkDirectionalParticles={2}
+ linkDirectionalParticleWidth={3}
+ linkDirectionalParticleSpeed={0.005}
+ d3VelocityDecay={0.3}
+ d3AlphaDecay={0.01}
+ onNodeClick={handleNodeClick}
+ width={580}
+ height={440}
+ />
+
+ {error && (
+
+ Note: Some graph data may be incomplete. {error}
+
+ )}
+
+
+ );
+}
diff --git a/components/TransactionTable.tsx b/components/search/TransactionTable.tsx
similarity index 77%
rename from components/TransactionTable.tsx
rename to components/search/TransactionTable.tsx
index a52d878..89d1421 100644
--- a/components/TransactionTable.tsx
+++ b/components/search/TransactionTable.tsx
@@ -1,3 +1,4 @@
+
"use client"
import { useSearchParams } from "next/navigation"
@@ -8,6 +9,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Loader2 } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { toast } from "sonner"
interface Transaction {
id: string
@@ -15,12 +17,19 @@ interface Transaction {
to: string
value: string
timestamp: string
- type: "transfer" | "swap" | "inflow" | "outflow"
+ network?: string
+ gas?: number
+ gasPrice?: number
+ blockNumber?: number
+ nonce?: number
+ type?: "transfer" | "swap" | "inflow" | "outflow"
}
export default function TransactionTable() {
const searchParams = useSearchParams()
const address = searchParams.get("address")
+ const network = searchParams.get("network") || "mainnet"
+ const provider = searchParams.get("provider") || "etherscan"
const [transactions, setTransactions] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
@@ -30,8 +39,22 @@ export default function TransactionTable() {
if (address) {
setLoading(true)
setError(null)
- fetch(`/api/transactions?address=${address}&page=${page}&offset=20`)
- .then((res) => res.json())
+
+ const baseUrl = typeof window !== 'undefined'
+ ? window.location.origin
+ : process.env.NEXT_PUBLIC_URL || ''
+
+ const apiEndpoint = `${baseUrl}/api/transactions?address=${address}&page=${page}&offset=20&network=${network}&provider=${provider}`
+
+ console.log("Fetching transactions from:", apiEndpoint);
+
+ fetch(apiEndpoint)
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(`API responded with status: ${res.status}`);
+ }
+ return res.json();
+ })
.then((data) => {
if (data.error) {
throw new Error(data.error)
@@ -41,20 +64,28 @@ export default function TransactionTable() {
...tx,
type: categorizeTransaction(tx, address),
}))
+ console.log("Received transactions:", categorizedData.length);
setTransactions(categorizedData)
})
.catch((err) => {
console.error("Error fetching transactions:", err)
setError(err.message || "Failed to fetch transactions")
+ toast.error(`Transaction error: ${err.message || "Unknown error"}`)
})
.finally(() => setLoading(false))
}
- }, [address, page])
+ }, [address, page, network, provider])
const categorizeTransaction = (tx: Transaction, userAddress: string): Transaction["type"] => {
- if (tx.from === userAddress && tx.to === userAddress) return "swap"
- if (tx.from === userAddress) return "outflow"
- if (tx.to === userAddress) return "inflow"
+ if (!tx.from || !tx.to) return "transfer";
+
+ const userAddressLower = userAddress.toLowerCase();
+ const fromLower = typeof tx.from === 'string' ? tx.from.toLowerCase() : '';
+ const toLower = typeof tx.to === 'string' ? tx.to.toLowerCase() : '';
+
+ if (fromLower === userAddressLower && toLower === userAddressLower) return "swap"
+ if (fromLower === userAddressLower) return "outflow"
+ if (toLower === userAddressLower) return "inflow"
return "transfer"
}
@@ -93,19 +124,25 @@ export default function TransactionTable() {
To
Value
Timestamp
+ {provider === 'bitquery' && Network }
{transactions.map((tx) => (
- {tx.from.slice(0, 6)}...{tx.from.slice(-4)}
+ {tx.from && typeof tx.from === 'string'
+ ? `${tx.from.slice(0, 6)}...${tx.from.slice(-4)}`
+ : "Unknown"}
- {tx.to.slice(0, 6)}...{tx.to.slice(-4)}
+ {tx.to && typeof tx.to === 'string'
+ ? `${tx.to.slice(0, 6)}...${tx.to.slice(-4)}`
+ : "Unknown"}
{tx.value}
{new Date(tx.timestamp).toLocaleString()}
+ {provider === 'bitquery' && {tx.network} }
))}
@@ -176,9 +213,9 @@ export default function TransactionTable() {
- setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="bg-[#F5B056] text-white px-6 py-2 rounded-lg font-medium
@@ -186,10 +223,10 @@ export default function TransactionTable() {
disabled:bg-gray-400 disabled:text-gray-600 disabled:cursor-not-allowed"
>
Previous
-
-
+ setPage((p) => p + 1)}
disabled={transactions.length < 20}
className="bg-[#F5B056] text-white px-6 py-2 rounded-lg font-medium
@@ -197,10 +234,9 @@ export default function TransactionTable() {
disabled:bg-gray-400 disabled:text-gray-600 disabled:cursor-not-allowed"
>
Next
-
+
)
}
-
diff --git a/components/search/WalletInfo.tsx b/components/search/WalletInfo.tsx
new file mode 100644
index 0000000..8358d03
--- /dev/null
+++ b/components/search/WalletInfo.tsx
@@ -0,0 +1,339 @@
+"use client"
+
+import { useSearchParams } from "next/navigation"
+import { useEffect, useState, useRef } from "react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Loader2, AlertCircle, RefreshCw, Clock } from "lucide-react"
+import { Wallet, Coins, DollarSign, ListOrdered, Globe } from "lucide-react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import { ErrorCard } from "@/components/ui/error-card"
+
+// Only use timeout for Etherscan, not for Infura
+const ETHERSCAN_TIMEOUT = 120000; // 30 seconds
+
+interface WalletData {
+ address: string
+ balance: string
+ transactionCount: number
+ network?: string
+}
+
+export default function WalletInfo() {
+ const searchParams = useSearchParams()
+ const address = searchParams.get("address")
+ const network = searchParams.get("network") || "mainnet"
+ const provider = searchParams.get("provider") || "etherscan"
+ const [walletData, setWalletData] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [errorType, setErrorType] = useState<"timeout" | "network" | "api" | "notFound" | "unknown">("unknown")
+ const [usdValue, setUsdValue] = useState(null)
+ const [retryCount, setRetryCount] = useState(0)
+ const [isFallbackMode, setIsFallbackMode] = useState(false)
+ const abortControllerRef = useRef(null)
+ const [loadingTimeElapsed, setLoadingTimeElapsed] = useState(0);
+ const timeElapsedIntervalRef = useRef(null);
+
+ const fetchWalletData = async () => {
+ if (!address) return;
+
+ setLoading(true);
+ setError(null);
+ setLoadingTimeElapsed(0);
+
+ // Create a new AbortController for this request
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ abortControllerRef.current = new AbortController();
+ const signal = abortControllerRef.current.signal;
+
+ // Only set timeout for non-Infura providers
+ let timeoutId: NodeJS.Timeout | null = null;
+
+ if (provider !== 'infura') {
+ // Set up timeout only for non-Infura
+ timeoutId = setTimeout(() => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ setError(`The request took too long to complete. Try switching to Infura provider.`);
+ setErrorType("timeout");
+ setLoading(false);
+
+ // Clear time elapsed interval
+ if (timeElapsedIntervalRef.current) {
+ clearInterval(timeElapsedIntervalRef.current);
+ timeElapsedIntervalRef.current = null;
+ }
+ }
+ }, ETHERSCAN_TIMEOUT);
+ }
+
+ // Set up time elapsed counter
+ timeElapsedIntervalRef.current = setInterval(() => {
+ setLoadingTimeElapsed(prev => prev + 1);
+ }, 1000);
+
+ const baseUrl = typeof window !== 'undefined'
+ ? window.location.origin
+ : process.env.NEXT_PUBLIC_URL || '';
+
+ // Initial provider to try
+ let currentProvider = provider;
+ let apiEndpoint = `${baseUrl}/api/wallet?address=${address}&network=${network}&provider=${currentProvider}`;
+
+ console.log("Fetching wallet data from:", apiEndpoint);
+
+ // Only fetch USD rate for ETH networks
+ const isEthereumType = network === 'mainnet' || network === 'optimism' || network === 'arbitrum';
+
+ try {
+ // First attempt with specified provider
+ let response = await fetch(apiEndpoint, { signal });
+
+ // If Etherscan fails but we originally requested it, try with Infura as fallback
+ if (!response.ok && currentProvider === 'etherscan' && !isFallbackMode) {
+ console.log("Etherscan API failed, trying Infura as fallback");
+ setIsFallbackMode(true);
+ currentProvider = 'infura';
+ apiEndpoint = `${baseUrl}/api/wallet?address=${address}&network=${network}&provider=${currentProvider}`;
+ response = await fetch(apiEndpoint, { signal });
+ }
+
+ // If still not successful
+ if (!response.ok) {
+ if (response.status === 404) {
+ setErrorType("notFound");
+ throw new Error(`Address not found or invalid on ${network}`);
+ } else {
+ setErrorType("api");
+ throw new Error(`API responded with status: ${response.status}`);
+ }
+ }
+
+ const walletData = await response.json();
+ if (walletData.error) {
+ setErrorType("api");
+ throw new Error(walletData.error);
+ }
+
+ setWalletData(walletData);
+ console.log("Wallet data received:", walletData);
+
+ // Fetch USD value if applicable
+ if (isEthereumType) {
+ try {
+ const rateResponse = await fetch(`${baseUrl}/api/eth-usd-rate`, { signal });
+ const rateData = await rateResponse.json();
+
+ if (!rateData.error) {
+ const balanceParts = walletData.balance.split(" ");
+ if (balanceParts.length >= 2 && balanceParts[1] === "ETH") {
+ const ethBalance = Number.parseFloat(balanceParts[0]);
+ setUsdValue(ethBalance * rateData.rate);
+ } else {
+ setUsdValue(null);
+ }
+ }
+ } catch (err) {
+ console.warn("USD rate fetch failed:", err);
+ setUsdValue(null);
+ }
+ }
+
+ // Reset retry count on success
+ setRetryCount(0);
+
+ } catch (err: any) {
+ console.error("Error fetching wallet data:", err);
+
+ // Determine error type
+ if (err.name === 'AbortError' && provider === 'infura') {
+ console.error("Unexpected abort with Infura provider:", err);
+ setErrorType("api");
+ setError("The request to Infura was unexpectedly terminated. Please try again.");
+ } else if (err.name === 'AbortError') {
+ setErrorType("timeout");
+ setError("Request timed out. Try switching to Infura provider which has no timeout limits.");
+ } else if (err.message.includes('fetch') || err.message.includes('network')) {
+ setErrorType("network");
+ setError("Network error. Please check your internet connection.");
+ } else if (err.message.includes('not found') || err.message.includes('invalid')) {
+ setErrorType("notFound");
+ setError(err.message || "Address not found or invalid");
+ } else {
+ setErrorType("api");
+ setError(err.message || "Failed to fetch wallet data");
+ }
+
+ toast.error(`Wallet data error: ${err.message || "Unknown error"}`, {
+ description: "Try switching to a different provider or checking the address",
+ action: {
+ label: 'Retry',
+ onClick: () => handleRetry(),
+ },
+ });
+ } finally {
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId);
+ }
+ setLoading(false);
+
+ // Clear time elapsed interval
+ if (timeElapsedIntervalRef.current) {
+ clearInterval(timeElapsedIntervalRef.current);
+ timeElapsedIntervalRef.current = null;
+ }
+ }
+ };
+
+ // Initial data fetch
+ useEffect(() => {
+ fetchWalletData();
+
+ // Cleanup function
+ return () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+
+ if (timeElapsedIntervalRef.current) {
+ clearInterval(timeElapsedIntervalRef.current);
+ }
+ };
+ }, [address, network, provider]);
+
+ const handleRetry = () => {
+ if (retryCount >= 3) {
+ // If we've tried 3 times, suggest using a different provider
+ toast.info("Multiple retry attempts failed. Consider trying a different provider.");
+ }
+ setRetryCount(prevCount => prevCount + 1);
+ fetchWalletData();
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ {provider === 'infura' ? 'Loading wallet data from Infura...' : 'Loading wallet info...'}
+
+
+
+
+ Time elapsed: {loadingTimeElapsed}s
+
+
+ {retryCount > 0 && (
+
Attempt {retryCount + 1}
+ )}
+
+ {provider === 'infura' && (
+
+ Infura searches have no timeout limit. Please be patient.
+
+ )}
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!walletData) {
+ return null
+ }
+
+ // Determine which block explorer to use based on network
+ const getBlockExplorerUrl = () => {
+ if (network === 'mainnet') {
+ return 'https://etherscan.io/address/';
+ } else if (network === 'optimism') {
+ return 'https://optimistic.etherscan.io/address/';
+ } else if (network === 'arbitrum') {
+ return 'https://arbiscan.io/address/';
+ }
+ return null;
+ };
+
+ const blockExplorerUrl = getBlockExplorerUrl();
+
+ return (
+
+
+
+ Wallet Information
+ {isFallbackMode && (
+ Using Infura (Fallback)
+ )}
+
+
+
+
+
+
+
+
+
Balance: {walletData.balance}
+
+
+ {usdValue !== null && (
+
+
+
USD Value: ${usdValue.toFixed(2)}
+
+ )}
+
+
+
+
Transaction Count: {walletData.transactionCount}
+
+
+ {walletData.network && (
+
+
+
Network: {walletData.network}
+
+ )}
+
+
+
+ )
+}
diff --git a/components/setting_ui/ImageUploader.tsx b/components/setting_ui/ImageUploader.tsx
new file mode 100644
index 0000000..8e6b9af
--- /dev/null
+++ b/components/setting_ui/ImageUploader.tsx
@@ -0,0 +1,119 @@
+'use client';
+import React, { useState, useRef } from 'react';
+import { Camera, Image as ImageIcon, X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface ImageUploaderProps {
+ currentImage: string | null;
+ onImageChange: (imageDataUrl: string | null) => void;
+ className?: string;
+ type: 'profile' | 'background';
+}
+
+const ImageUploader: React.FC = ({
+ currentImage,
+ onImageChange,
+ className,
+ type
+}) => {
+ const [isHovering, setIsHovering] = useState(false);
+ const inputRef = useRef(null);
+
+ const handleClick = () => {
+ inputRef.current?.click();
+ };
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ if (event.target?.result) {
+ onImageChange(event.target.result as string);
+ }
+ };
+ reader.readAsDataURL(file);
+
+ // Reset input value so the same file can be selected again
+ e.target.value = '';
+ };
+
+ const removeImage = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onImageChange(null);
+ };
+
+ return (
+ setIsHovering(true)}
+ onMouseLeave={() => setIsHovering(false)}
+ >
+ {currentImage ? (
+ <>
+
+
+
+ {isHovering && (
+
+
+
+ )}
+
+ >
+ ) : (
+
+ {type === 'profile' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {type === 'profile' ? 'Upload profile picture' : 'Upload background image'}
+
+
+ )}
+
+
+
+ );
+};
+
+export default ImageUploader;
\ No newline at end of file
diff --git a/components/setting_ui/ProfileSection.tsx b/components/setting_ui/ProfileSection.tsx
new file mode 100644
index 0000000..2054b45
--- /dev/null
+++ b/components/setting_ui/ProfileSection.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import React from 'react';
+import { useSettings } from '@/components/context/SettingsContext';
+import ImageUploader from './ImageUploader';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Save, UserRound } from 'lucide-react';
+import { toast } from 'sonner';
+
+const ProfileSection: React.FC = () => {
+ const { profile, updateProfile, saveProfile, hasUnsavedChanges } = useSettings();
+
+ const handleSave = () => {
+ console.log('Saving changes...');
+ saveProfile();
+ toast.success('Profile saved successfully');
+ };
+
+ return (
+
+
+ {/* Decorative elements */}
+
+
+
+
+ {/* Background Image */}
+
+ updateProfile({ backgroundImage: img })}
+ className="h-full object-cover"
+ />
+
+
+ {/* Profile Image */}
+
+ updateProfile({ profileImage: img })}
+ className="transition-transform duration-300 hover:scale-105 w-32 h-32 object-cover rounded-full"
+ />
+
+
+
+ {/* Username input */}
+
+
+
+
+ Username
+
+ {
+ console.log('New username:', e.target.value);
+ updateProfile({ username: e.target.value });
+ }}
+ placeholder="Your username"
+ className="bg-transparent border-2 border-[#f6b355]/50 text-white placeholder-[#f6b355]/50 focus:ring-2 focus:ring-[#f6b355] hover:border-[#f6b355] transition-all duration-200 rounded-[40px] py-2 px-4"
+ />
+
+
+
+
+
+ Save Changes
+
+
+
+
+
+
+ );
+};
+
+export default ProfileSection;
\ No newline at end of file
diff --git a/components/setting_ui/SettingLayout.tsx b/components/setting_ui/SettingLayout.tsx
new file mode 100644
index 0000000..ab89ff6
--- /dev/null
+++ b/components/setting_ui/SettingLayout.tsx
@@ -0,0 +1,117 @@
+
+'use client';
+import React, { useState } from 'react';
+import { UserRound, Wallet, Cloud } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useIsMobile } from '@/hooks/use-mobile';
+
+type Tab = 'profile' | 'wallet' | 'sync';
+
+interface SettingLayoutProps {
+ profileSection: React.ReactNode;
+ walletSection: React.ReactNode;
+ syncSection?: React.ReactNode;
+}
+
+const SettingLayout: React.FC = ({
+ profileSection,
+ walletSection,
+ syncSection
+}) => {
+ const [activeTab, setActiveTab] = useState('profile');
+ const isMobile = useIsMobile();
+
+ const tabs = [
+ { id: 'profile', label: 'Profile', icon: },
+ { id: 'wallet', label: 'Wallet Addresses', icon: },
+ { id: 'sync', label: 'Sync', icon: },
+ ];
+
+ return (
+
+ {/* Enhanced background gradients */}
+
+
+
+
+
+
+ SETTINGS
+
+
+ {/* Enhanced tabs */}
+
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.id as Tab)}
+ >
+ {tab.icon}
+ {!isMobile && {tab.label} }
+
+ ))}
+
+
+
+ {/* Content with improved transitions */}
+
+ {activeTab === 'profile' && (
+
+ {profileSection}
+
+ )}
+ {activeTab === 'wallet' && (
+
+ {walletSection}
+
+ )}
+ {activeTab === 'sync' && syncSection && (
+
+ {syncSection}
+
+ )}
+
+
+
+
+ );
+};
+
+export default SettingLayout;
diff --git a/components/setting_ui/SettingSync.tsx b/components/setting_ui/SettingSync.tsx
new file mode 100644
index 0000000..86f2711
--- /dev/null
+++ b/components/setting_ui/SettingSync.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { useSettings } from "@/components/context/SettingsContext";
+import { Button } from "@/components/ui/button";
+import { Cloud, Database, RefreshCw } from "lucide-react";
+import { supabase } from "@/src/integrations/supabase/client";
+import { toast } from "sonner";
+
+const SettingSync: React.FC = () => {
+ const { syncWithSupabase, isSyncing, addWallet, wallets } = useSettings();
+ const [user, setUser] = useState(null);
+ const [lastSynced, setLastSynced] = useState(null);
+ const [newWalletAddress, setNewWalletAddress] = useState("");
+
+ useEffect(() => {
+ const getUser = async () => {
+ try {
+ const { data } = await supabase.auth.getUser();
+ setUser(data.user);
+ const lastSyncTime = localStorage.getItem("lastProfileSync");
+ setLastSynced(lastSyncTime);
+ } catch (error) {
+ console.error("Error getting user:", error);
+ }
+ };
+ getUser();
+ }, []);
+
+ const handleAddWallet = async () => {
+ if (!newWalletAddress) {
+ toast.error("Please enter a wallet address");
+ return;
+ }
+ try {
+ await addWallet(newWalletAddress);
+ setNewWalletAddress("");
+ } catch (error) {
+ console.error("Error adding wallet:", error);
+ toast.error("Failed to add wallet");
+ }
+ };
+
+ const handleSync = async () => {
+ try {
+ await syncWithSupabase();
+ const now = new Date().toISOString();
+ localStorage.setItem("lastProfileSync", now);
+ setLastSynced(now);
+ toast.success("Sync completed successfully");
+ } catch (error) {
+ console.error("Error syncing profile:", error);
+ toast.error("Failed to sync profile");
+ }
+ };
+
+ if (!user) {
+ return (
+
+
+
+ Please log in to use sync features.
+
+
+
+ );
+ }
+
+ const formatLastSynced = () => {
+ if (!lastSynced) return "Never";
+ try {
+ const date = new Date(lastSynced);
+ return date.toLocaleString();
+ } catch (e) {
+ return "Unknown";
+ }
+ };
+
+ return (
+
+
+
+
+
+ Profile Synchronization
+
+
+
+
+
Add Wallet
+
+ setNewWalletAddress(e.target.value)}
+ placeholder="Enter wallet address"
+ className="flex-1 px-3 py-2 border border-white/30 rounded-md bg-black/30 text-white"
+ />
+
+ Add Wallet
+
+
+
+
Current Wallets:
+
+ {wallets.map((wallet) => (
+
+ {wallet.address} {wallet.isDefault && "(Default)"}
+
+ ))}
+
+
+
+
+
+
+
+
Database Sync
+
+ Synchronize your profile and wallet settings with the cloud database.
+
+
+ Last synced: {formatLastSynced()}
+
+
+
+ {isSyncing ? (
+
+ ) : (
+
+ )}
+ {isSyncing ? "Syncing..." : "Sync Now"}
+
+
+
+
+
+
What gets synchronized?
+
+
+
Profile Data
+
+ Username
+ Profile Image
+ Background Image
+
+
+
+
Wallet Data
+
+ Wallet Addresses
+ Default Wallet Selection
+
+
+
+
+
+
+
+ );
+};
+
+export default SettingSync;
\ No newline at end of file
diff --git a/components/setting_ui/WalletSection.tsx b/components/setting_ui/WalletSection.tsx
new file mode 100644
index 0000000..7d110ea
--- /dev/null
+++ b/components/setting_ui/WalletSection.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+import React, { useState } from "react";
+import { useSettings } from "@/components/context/SettingsContext";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Wallet as WalletIcon, Plus, Trash2, Check, CreditCard } from "lucide-react";
+import { toast } from "sonner";
+
+// Không cần định nghĩa lại Wallet vì đã có trong SettingsContext
+
+const WalletSection: React.FC = () => {
+ const { wallets = [], addWallet, removeWallet, setDefaultWallet } = useSettings(); // Default to empty array
+ const [newWalletAddress, setNewWalletAddress] = useState("");
+ const [isAdding, setIsAdding] = useState(false);
+
+ const handleAddWallet = () => {
+ if (!newWalletAddress.trim()) {
+ toast.error("Wallet address cannot be empty");
+ return;
+ }
+ if (newWalletAddress.length < 5) {
+ toast.error("Please enter a valid wallet address");
+ return;
+ }
+ if (wallets.some((wallet) => wallet.address.toLowerCase() === newWalletAddress.toLowerCase())) {
+ toast.error("This wallet address is already added");
+ return;
+ }
+ addWallet(newWalletAddress);
+ setNewWalletAddress("");
+ setIsAdding(false);
+ toast.success("Wallet added successfully");
+ };
+
+ const handleDeleteWallet = (address: string) => {
+ removeWallet(address);
+ toast.success("Wallet removed successfully");
+ };
+
+ const handleSetDefault = (address: string) => {
+ setDefaultWallet(address);
+ toast.success("Default wallet updated");
+ };
+
+ return (
+
+
+
+
+
+ Wallet Addresses
+
+ {!isAdding && (
+
setIsAdding(true)}
+ >
+
+ Add Wallet
+
+ )}
+
+
+ {isAdding && (
+
+
+
setNewWalletAddress(e.target.value)}
+ placeholder="Enter wallet address"
+ className="bg-black/60 border border-[#f6b355]/60 rounded-[40px] text-white input-focus hover:border-amber/50 transition-all duration-200"
+ />
+
+
+ Add
+
+ {
+ setIsAdding(false);
+ setNewWalletAddress("");
+ }}
+ >
+ Cancel
+
+
+
+
+ )}
+
+
+ {wallets.length === 0 ? (
+
+
+
No wallets added yet
+
Click "Add Wallet" to get started
+
+ ) : (
+ wallets.map((wallet) => (
+
+
+
+
+
+
+
+ {wallet.address}
+
+ {wallet.isDefault && (
+
+
+ Default wallet
+
+ )}
+
+
+
+
+ {!wallet.isDefault && (
+ handleSetDefault(wallet.address)} // Dùng address thay vì id
+ >
+ Set Default
+
+ )}
+ handleDeleteWallet(wallet.address)} // Dùng address thay vì id
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+};
+
+export default WalletSection;
\ No newline at end of file
diff --git a/components/ClientContent.tsx b/components/table/ClientContent.tsx
similarity index 96%
rename from components/ClientContent.tsx
rename to components/table/ClientContent.tsx
index 44bd465..c194ad7 100644
--- a/components/ClientContent.tsx
+++ b/components/table/ClientContent.tsx
@@ -3,9 +3,9 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getCoins } from "@/lib/api/coinApi";
-import HeroSection from "@/components/HeroSection";
-import CoinTable from "@/components/CoinTable";
-import CoinCard from "@/components/CoinCard";
+import HeroSection from "@/components/table/HeroSection";
+import CoinTable from "@/components/table/CoinTable";
+import CoinCard from "./CoinCard";
import Loader from "@/components/Loader";
export const ClientContent = () => {
diff --git a/components/CoinCard.tsx b/components/table/CoinCard.tsx
similarity index 100%
rename from components/CoinCard.tsx
rename to components/table/CoinCard.tsx
diff --git a/components/CoinDetaiModal.tsx b/components/table/CoinDetaiModal.tsx
similarity index 99%
rename from components/CoinDetaiModal.tsx
rename to components/table/CoinDetaiModal.tsx
index a31d926..f56f127 100644
--- a/components/CoinDetaiModal.tsx
+++ b/components/table/CoinDetaiModal.tsx
@@ -20,8 +20,8 @@ import { formatNumber } from "@/lib/format";
import { formatPercentage } from "@/lib/format";
import { getColorForPercentChange } from "@/lib/format";
import { useState } from "react";
-import { Button } from "./ui/button";
-import { Separator } from "./ui/separator";
+import { Button } from "../ui/button";
+import { Separator } from "../ui/separator";
interface CoinDetailModalProps {
coinId: string | null;
diff --git a/components/CoinTable.tsx b/components/table/CoinTable.tsx
similarity index 99%
rename from components/CoinTable.tsx
rename to components/table/CoinTable.tsx
index 65d2bcb..294f9c3 100644
--- a/components/CoinTable.tsx
+++ b/components/table/CoinTable.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
// import { HeaderTable } from '@/components/HeaderTable';
-import Loader from "./Loader";
+import Loader from "../Loader";
import {
ChevronUp,
ChevronDown,
diff --git a/components/DashboardTable.tsx b/components/table/DashboardTable.tsx
similarity index 100%
rename from components/DashboardTable.tsx
rename to components/table/DashboardTable.tsx
diff --git a/components/HeaderTable.tsx b/components/table/HeaderTable.tsx
similarity index 100%
rename from components/HeaderTable.tsx
rename to components/table/HeaderTable.tsx
diff --git a/components/HeroSection.tsx b/components/table/HeroSection.tsx
similarity index 100%
rename from components/HeroSection.tsx
rename to components/table/HeroSection.tsx
diff --git a/components/TopMoversSection.tsx b/components/table/TopMoversSection.tsx
similarity index 100%
rename from components/TopMoversSection.tsx
rename to components/table/TopMoversSection.tsx
diff --git a/components/transactions/CoinAnalytics.tsx b/components/transactions/CoinAnalytics.tsx
new file mode 100644
index 0000000..7af07a5
--- /dev/null
+++ b/components/transactions/CoinAnalytics.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+import { useEffect, useState } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { fetchChainAnalytics, ChainAnalytics, CoinOption } from "@/services/cryptoService";
+import { Loader2, AlertCircle, RefreshCcw, Users, ArrowDownToLine, Coins, Building2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface CoinAnalyticsProps {
+ selectedCoin: CoinOption;
+}
+
+export default function CoinAnalytics({ selectedCoin }: CoinAnalyticsProps) {
+ const [analytics, setAnalytics] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [retryCount, setRetryCount] = useState(0);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await fetchChainAnalytics(selectedCoin.id);
+ setAnalytics(data);
+ } catch (err) {
+ setError('Failed to fetch analytics data');
+ console.error('Error fetching analytics:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [selectedCoin.id, retryCount]);
+
+ const handleRetry = () => {
+ setRetryCount(prev => prev + 1);
+ };
+
+ const formatNumber = (num: number): string => {
+ if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
+ if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
+ if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
+ if (num >= 1e3) return `${(num / 1e3).toFixed(2)}K`;
+ return num.toFixed(2);
+ };
+
+ const formatPercentage = (value: number): string => {
+ const sign = value >= 0 ? '+' : '';
+ return `${sign}${value.toFixed(2)}%`;
+ };
+
+ if (loading) {
+ return (
+
+ {[1, 2, 3, 4].map((i) => (
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (error || !analytics) {
+ return (
+
+
+
{error}
+
+
+ Retry
+
+
+ );
+ }
+
+ return (
+
+ {/* UAW */}
+
+
+
+
+ UAW
+
+
+
+
+ {formatNumber(analytics.uniqueActiveWallets)}
+
+ = 0 ? 'text-green-500' : 'text-red-500'}`}>
+ {formatPercentage(analytics.dailyChange.uaw)}
+
+
+
+
+ {/* Incoming Transactions */}
+
+
+
+
+
+
+ {formatNumber(analytics.incomingTransactions)}
+
+ = 0 ? 'text-green-500' : 'text-red-500'}`}>
+ {formatPercentage(analytics.dailyChange.transactions)}
+
+
+
+
+ {/* Incoming Volume */}
+
+
+
+
+ Incoming Volume
+
+
+
+
+ ${formatNumber(analytics.incomingVolume)}
+
+ = 0 ? 'text-green-500' : 'text-red-500'}`}>
+ {formatPercentage(analytics.dailyChange.volume)}
+
+
+
+
+ {/* Contract Balance */}
+
+
+
+
+ Contract Balance
+
+
+
+
+ ${formatNumber(analytics.contractBalance)}
+
+ = 0 ? 'text-green-500' : 'text-red-500'}`}>
+ {formatPercentage(analytics.dailyChange.balance)}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/transactions/NetworkStats.tsx b/components/transactions/NetworkStats.tsx
new file mode 100644
index 0000000..ffaf6b2
--- /dev/null
+++ b/components/transactions/NetworkStats.tsx
@@ -0,0 +1,185 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Clock, Loader2, Gauge, Calculator } from "lucide-react"
+import { toast } from "@/components/ui/use-toast"
+import NetworkTransactionTable from '@/components/transactions/NetworkTransactionTable';
+
+interface Stats {
+ transactions24h: number;
+ pendingTransactions: number;
+ networkFee: number;
+ avgGasFee: number;
+ totalTransactionAmount: number;
+}
+
+const initialStats: Stats = {
+ transactions24h: 0,
+ pendingTransactions: 0,
+ networkFee: 0,
+ avgGasFee: 0,
+ totalTransactionAmount: 0,
+};
+
+export default function NetworkStats() {
+ const [stats, setStats] = useState(initialStats);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [updateKey, setUpdateKey] = useState(0); // Used to trigger table updates
+
+ const fetchNetworkStats = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ // Batch API calls together
+ const [gasResponse, blockResponse] = await Promise.all([
+ fetch('/api/etherscan?module=gastracker&action=gasoracle'),
+ fetch('/api/etherscan?module=proxy&action=eth_blockNumber')
+ ]);
+
+ // Handle gas price data
+ if (!gasResponse.ok) throw new Error('Failed to fetch gas prices');
+ const gasData = await gasResponse.json();
+
+ // Handle block number data
+ if (!blockResponse.ok) throw new Error('Failed to fetch latest block');
+ const blockData = await blockResponse.json();
+
+ // Process gas data
+ if (gasData.status === "1") {
+ setStats(prev => ({
+ ...prev,
+ networkFee: parseFloat(gasData.result.SafeGasPrice),
+ avgGasFee: parseFloat(gasData.result.ProposeGasPrice)
+ }));
+ }
+
+ // Process block data
+ const latestBlock = parseInt(blockData.result, 16);
+ const blocksIn24h = Math.floor(86400 / 15); // Approximate blocks in 24h
+
+ // Fetch transaction count with delay to respect rate limit
+ await new Promise(resolve => setTimeout(resolve, 200));
+ const txCountResponse = await fetch(
+ `/api/etherscan?module=proxy&action=eth_getBlockTransactionCountByNumber&tag=${latestBlock.toString(16)}`
+ );
+
+ if (!txCountResponse.ok) throw new Error('Failed to fetch transaction count');
+ const txCountData = await txCountResponse.json();
+ const txCount = parseInt(txCountData.result, 16);
+
+ // Update stats
+ setStats(prev => ({
+ ...prev,
+ transactions24h: txCount * blocksIn24h,
+ pendingTransactions: Math.floor(Math.random() * 100) + 50 // Temporary mock data for pending transactions
+ }));
+
+ // Trigger table update
+ setUpdateKey(prev => prev + 1);
+ } catch (error) {
+ console.error('Error fetching network stats:', error);
+ setError('Failed to fetch network stats');
+ toast({
+ title: "Error",
+ description: "Failed to fetch network stats. Please try again later.",
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchNetworkStats();
+ const interval = setInterval(fetchNetworkStats, 15000); // Update every 15 seconds
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ Transactions (24h)
+
+
+
+
+ {loading ? (
+
+ ) : (
+ stats.transactions24h.toLocaleString()
+ )}
+
+
+
+
+
+
+
+
+ Pending Txns
+
+
+
+
+ {loading ? (
+
+ ) : (
+ stats.pendingTransactions.toLocaleString()
+ )}
+
+
+
+
+
+
+
+
+ Network Fee
+
+
+
+
+ {loading ? (
+
+ ) : (
+ `${stats.networkFee.toFixed(2)} Gwei`
+ )}
+
+
+
+
+
+
+
+
+ AVG Gas Fee
+
+
+
+
+ {loading ? (
+
+ ) : (
+ `${stats.avgGasFee.toFixed(2)} Gwei`
+ )}
+
+
+
+
+
+ {/* Transaction Table */}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/transactions/NetworkTransactionTable.tsx b/components/transactions/NetworkTransactionTable.tsx
new file mode 100644
index 0000000..f3b23df
--- /dev/null
+++ b/components/transactions/NetworkTransactionTable.tsx
@@ -0,0 +1,278 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { Button } from "@/components/ui/button"
+import Link from 'next/link'
+import { Eye, ChevronLeft, ChevronRight, Download, Copy } from 'lucide-react'
+import { toast, useToast } from "@/components/ui/use-toast"
+import { ethers } from 'ethers';
+
+interface CoinOption {
+ id: string;
+ name: string;
+}
+
+interface NetworkTransactionTableProps {
+ selectedCoin?: CoinOption | null;
+}
+
+interface Transaction {
+ hash: string;
+ method: string;
+ block?: string;
+ age?: string;
+ from: string;
+ to: string;
+ value: string;
+ gasPrice?: string;
+ gasUsed?: string;
+ timestamp: number;
+}
+
+export default function NetworkTransactionTable({ selectedCoin }: NetworkTransactionTableProps) {
+ const [transactions, setTransactions] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [isMobile, setIsMobile] = useState(false);
+ const { toast } = useToast();
+
+ const copyToClipboard = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast({
+ title: "Copied!",
+ description: "Address copied to clipboard",
+ });
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+ };
+
+ const truncateAddress = (address: string) => {
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
+
+ };
+
+ const getRelativeTime = (timestamp: number | string) => {
+ const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : timestamp * 1000;
+ const now = Date.now();
+ const diff = now - ts;
+
+ if (diff < 0) return "Just now";
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return `${seconds} secs ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes} mins ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours} hrs ago`;
+ const days = Math.floor(hours / 24);
+ return `${days} days ago`;
+ };
+
+ useEffect(() => {
+ const fetchTransactions = async () => {
+ try {
+ setIsLoading(true);
+ setError(null);
+
+ let response;
+ if (selectedCoin) {
+ // Fetch token transactions
+ response = await fetch(`/api/token-transactions?coinId=${selectedCoin.id}&page=${page}&offset=50`);
+ } else {
+ // Fetch latest block transactions (default to ETH)
+ const blockResponse = await fetch('/api/etherscan?module=proxy&action=eth_blockNumber');
+ if (!blockResponse.ok) throw new Error('Failed to fetch latest block');
+ const blockData = await blockResponse.json();
+
+ response = await fetch(
+ `/api/etherscan?module=proxy&action=eth_getBlockByNumber&tag=${blockData.result}&boolean=true`
+ );
+ }
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch transactions');
+ }
+
+ const data = await response.json();
+
+
+ if (data.error) {
+ throw new Error(data.error);
+ }
+
+ let formattedTransactions: Transaction[];
+ if (selectedCoin) {
+ // Token transactions are already formatted from the API
+ formattedTransactions = data;
+ } else {
+ // Format ETH transactions from block data
+ formattedTransactions = data.result.transactions.slice(0, 50).map((tx: any) => {
+ const timestamp = parseInt(data.result.timestamp, 16);
+ return {
+ hash: tx.hash,
+ method: tx.input === '0x' ? 'Transfer' : 'Contract Interaction',
+ block: parseInt(tx.blockNumber, 16).toString(),
+ age: getRelativeTime(timestamp),
+ from: tx.from,
+ to: tx.to || 'Contract Creation',
+ value: `${ethers.utils.formatEther(tx.value)} ETH`,
+ gasPrice: tx.gasPrice,
+ gasUsed: tx.gas,
+ timestamp
+ };
+ });
+ }
+
+ setTransactions(formattedTransactions);
+ } catch (error) {
+ console.error('Error fetching transactions:', error);
+ setError(error instanceof Error ? error.message : 'Failed to fetch transactions');
+ toast({
+ title: "Error",
+ description: "Failed to fetch transactions. Please try again later.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchTransactions();
+ const interval = selectedCoin ? null : setInterval(fetchTransactions, 15000);
+ return () => {
+ if (interval) clearInterval(interval);
+ };
+ }, [selectedCoin, page]);
+
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+ handleResize();
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ return (
+
+
+
+
+
+ Txn Hash
+ Method
+ {!selectedCoin && Block }
+ Age
+ From
+ To
+ Value
+
+
+
+ {isLoading ? (
+
+
+
+
+
Loading transactions...
+
+
+
+ ) : error ? (
+
+
+ {error}
+
+
+ ) : transactions.length === 0 ? (
+
+
+ No transactions found
+
+
+ ) : (
+ transactions.map((tx, index) => (
+
+
+
+
+
+
+
+
+
+
+ {truncateAddress(tx.hash)}
+
+
+ copyToClipboard(tx.hash)}
+ className="h-5 w-5 p-0"
+ >
+
+
+
+
+
+
+ {tx.method}
+
+
+ {!selectedCoin && (
+
+
+
+ {tx.block}
+
+
+
+ )}
+ {typeof tx.timestamp === 'number' ? getRelativeTime(tx.timestamp) : getRelativeTime(new Date(tx.timestamp).getTime() / 1000)}
+
+
+
+
+ {truncateAddress(tx.from)}
+
+
+ copyToClipboard(tx.from)}
+ className="h-5 w-5 p-0"
+ >
+
+
+
+
+
+
+
+
+ {truncateAddress(tx.to)}
+
+
+ copyToClipboard(tx.to)}
+ className="h-5 w-5 p-0"
+ >
+
+
+
+
+ {tx.value}
+
+ ))
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/transactions/RevenueGraph.tsx b/components/transactions/RevenueGraph.tsx
new file mode 100644
index 0000000..1091581
--- /dev/null
+++ b/components/transactions/RevenueGraph.tsx
@@ -0,0 +1,385 @@
+'use client';
+
+import { useEffect, useState, useCallback, useMemo, memo } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+import { fetchHistoricalData, fetchAvailableCoins, CryptoMarketData, CoinOption, TOKEN_CONTRACTS } from "@/services/cryptoService";
+import { Loader2, AlertCircle, RefreshCcw, TrendingUp, ChevronDown } from "lucide-react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+import CoinAnalytics from './CoinAnalytics';
+import { motion, AnimatePresence } from "framer-motion";
+import dynamic from 'next/dynamic';
+
+// Optimize chart loading with proper SSR handling and caching
+const Chart = dynamic(() => import('recharts').then(mod => mod.ResponsiveContainer), {
+ ssr: false,
+ loading: () => (
+
+
+
+ )
+});
+
+interface ChartData {
+ date: string;
+ price: number;
+ volume: number;
+}
+
+// Memoized components
+const LoadingState = memo(({ coinName }: { coinName?: string }) => (
+
+
+
+
+
Loading {coinName || ''} data...
+
+));
+LoadingState.displayName = "LoadingState";
+
+const ErrorState = memo(({ error, onRetry }: { error: string; onRetry: () => void }) => (
+
+
+
+
+
{error}
+
+
+ Retry
+
+
+));
+ErrorState.displayName = "ErrorState";
+
+interface RevenueGraphProps {
+ onCoinChange: (coin: CoinOption | null) => void;
+}
+
+const RevenueGraph: React.FC = ({ onCoinChange }) => {
+ const [data, setData] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [retryCount, setRetryCount] = useState(0);
+ const [selectedCoin, setSelectedCoin] = useState(null);
+ const [availableCoins, setAvailableCoins] = useState([]);
+ const [loadingCoins, setLoadingCoins] = useState(true);
+
+ // Memoize handlers
+ const handleRetry = useCallback(() => {
+ setRetryCount(prev => prev + 1);
+ }, []);
+
+ const handleCoinChange = useCallback((coinId: string) => {
+ const coin = availableCoins.find(c => c.id === coinId);
+ if (coin) {
+ setSelectedCoin(coin);
+ onCoinChange(coin);
+ setError(null);
+ }
+ }, [availableCoins, onCoinChange]);
+
+ // Fetch available coins
+ useEffect(() => {
+ let mounted = true;
+ const fetchCoins = async () => {
+ try {
+ setLoadingCoins(true);
+ const coins = await fetchAvailableCoins();
+ if (!mounted) return;
+
+ // Filter coins to only those with contract addresses
+ const supportedCoins = coins.filter(coin => TOKEN_CONTRACTS[coin.id]);
+ setAvailableCoins(supportedCoins);
+
+ if (supportedCoins.length > 0) {
+ const ethereum = supportedCoins.find(c => c.id === 'ethereum') || supportedCoins[0];
+ setSelectedCoin(ethereum);
+ onCoinChange(ethereum);
+ }
+ } catch (err) {
+ console.error('Error fetching coins:', err);
+ } finally {
+ if (mounted) {
+ setLoadingCoins(false);
+ }
+ }
+ };
+
+ fetchCoins();
+ return () => { mounted = false; };
+ }, [onCoinChange]);
+
+ // Optimize data fetching with proper cleanup and error handling
+ useEffect(() => {
+ let mounted = true;
+ let retryAttempt = 0;
+ const maxRetries = 3;
+ const retryDelay = 2000; // 2 seconds
+
+ const fetchData = async () => {
+ if (!selectedCoin) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const coinData = await fetchHistoricalData(selectedCoin.id, 30);
+
+ if (!mounted) return;
+
+ if (!coinData.prices || !coinData.total_volumes ||
+ coinData.prices.length === 0 || coinData.total_volumes.length === 0) {
+ throw new Error('No data available for this coin');
+ }
+
+ const chartData: ChartData[] = coinData.prices.map((price, index) => {
+ const date = new Date(price[0]);
+ return {
+ date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
+ price: Number(price[1].toFixed(2)),
+ volume: Number((coinData.total_volumes[index][1] / 1000000).toFixed(2))
+ };
+ });
+
+ setData(chartData);
+ setLoading(false);
+ retryAttempt = 0; // Reset retry counter on success
+ } catch (err) {
+ console.error('Error fetching data:', err);
+ if (mounted) {
+ if (retryAttempt < maxRetries) {
+ retryAttempt++;
+ console.log(`Retrying (${retryAttempt}/${maxRetries})...`);
+ setTimeout(fetchData, retryDelay);
+ } else {
+ setError(err instanceof Error ? err.message : 'Failed to fetch data');
+ setLoading(false);
+ }
+ }
+ }
+ };
+
+ // Add a small delay before fetching to prevent rate limiting
+ const timeoutId = setTimeout(fetchData, 100);
+
+ return () => {
+ mounted = false;
+ clearTimeout(timeoutId);
+ };
+ }, [selectedCoin?.id]);
+
+ // Memoize chart data
+ const chartConfig = useMemo(() => ({
+ gradients: (
+
+
+
+
+
+
+
+
+
+
+ ),
+ tooltipStyle: {
+ backgroundColor: 'rgba(17, 24, 39, 0.95)',
+ border: '1px solid rgba(75, 85, 99, 0.3)',
+ borderRadius: '12px',
+ boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
+ backdropFilter: 'blur(8px)',
+ },
+ customTooltip: (props: any) => {
+ if (!props.active || !props.payload || !props.payload.length) {
+ return null;
+ }
+
+ const priceValue = props.payload.find((p: any) => p.dataKey === 'price');
+ const volumeValue = props.payload.find((p: any) => p.dataKey === 'volume');
+
+ return (
+
+
{props.label}
+ {priceValue && (
+
+ Price: ${priceValue.value.toLocaleString()}
+
+ )}
+ {volumeValue && (
+
+ Volume: {volumeValue.value.toFixed(0)}M
+
+ )}
+
+ );
+ }
+ }), []);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {selectedCoin?.name || 'Loading...'} Price & Volume
+
+
+
+
+ {loadingCoins ? (
+
+
+ Loading...
+
+ ) : (
+
+
+
+
+ )}
+
+
+
+ {availableCoins.map((coin) => (
+
+
+ {coin.symbol}
+ -
+ {coin.name}
+
+
+ ))}
+
+
+
+
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
+ ) : (
+
+
+
+ {chartConfig.gradients}
+
+ `$${value.toLocaleString()}`}
+ tick={{ fill: '#9ca3af' }}
+ width={80}
+ padding={{ top: 0 }}
+ />
+ `${value.toFixed(0)}M`}
+ tick={{ fill: '#9ca3af' }}
+ width={70}
+ padding={{ top: 20 }}
+ />
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ {selectedCoin && (
+
+
+
+ )}
+
+
+ );
+};
+RevenueGraph.displayName = "RevenueGraph";
+
+export default RevenueGraph;
+
diff --git a/components/transactions/TransactionExplorer.tsx b/components/transactions/TransactionExplorer.tsx
new file mode 100644
index 0000000..3fc461b
--- /dev/null
+++ b/components/transactions/TransactionExplorer.tsx
@@ -0,0 +1,500 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { Button } from "@/components/ui/button"
+import Link from 'next/link'
+import { Eye, ChevronLeft, ChevronRight, Download, Copy } from 'lucide-react'
+import { toast } from "@/components/ui/use-toast"
+import { utils } from 'ethers'
+
+interface Stats {
+ transactions24h: number;
+ pendingTransactions: number;
+ networkFee: number;
+ avgGasFee: number;
+ totalTransactionAmount: number;
+}
+
+// Initial state
+const initialStats: Stats = {
+ transactions24h: 0,
+ pendingTransactions: 0,
+ networkFee: 0,
+ avgGasFee: 0,
+ totalTransactionAmount: 0,
+};
+
+export default function TransactionExplorer() {
+ // State variables
+ const [transactions, setTransactions] = useState([]);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [selectedMethod, setSelectedMethod] = useState(null);
+ const [totalPages] = useState(5000);
+ const [isMobile, setIsMobile] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Etherscan API configuration
+ const ETHERSCAN_API_KEY = '6U137E3DGFMCCBQA8E3CAR1P1UW7EV8A6S';
+
+ interface MethodSignatures {
+ [key: string]: string;
+ }
+
+ const knownMethods: MethodSignatures = {
+ '0xa9059cbb': 'Transfer',
+ '0x23b872dd': 'TransferFrom',
+ '0x095ea7b3': 'Approve',
+ '0x70a08231': 'BalanceOf',
+ '0x18160ddd': 'TotalSupply',
+ '0x313ce567': 'Decimals',
+ '0x06fdde03': 'Name',
+ '0x95d89b41': 'Symbol',
+ '0xd0e30db0': 'Deposit',
+ '0x2e1a7d4d': 'Withdraw',
+ '0x3593564c': 'Execute',
+ '0x4a25d94a': 'SwapExactTokensForTokens',
+ '0x7ff36ab5': 'SwapExactETHForTokens',
+ '0x791ac947': 'SwapExactTokensForETH',
+ '0xfb3bdb41': 'SwapETHForExactTokens',
+ '0x5c11d795': 'SwapTokensForExactTokens',
+ '0xb6f9de95': 'Claim',
+ '0x6a627842': 'Mint',
+ '0xa0712d68': 'Mint',
+ };
+
+ const getTransactionMethod = (input: string): string => {
+ if (input === '0x') return 'Transfer';
+
+ const functionSelector = input.slice(0, 10).toLowerCase();
+
+ if (knownMethods[functionSelector]) {
+ return knownMethods[functionSelector];
+ }
+
+ return 'Swap';
+ };
+
+ // Function to get relative time
+ const getRelativeTime = (timestamp: number) => {
+ const now = Date.now();
+ const diff = now - timestamp * 1000;
+
+ // Ensure diff is not negative
+ if (diff < 0) return "Just now";
+
+ const seconds = Math.floor(diff / 1000);
+
+ if (seconds < 60) return `${seconds} secs ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes} mins ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours} hrs ago`;
+ const days = Math.floor(hours / 24);
+ return `${days} days ago`;
+ };
+
+ // Function to truncate addresses
+ const truncateAddress = (address: string) => {
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
+ };
+
+ // Fetch latest blocks and their transactions
+ const fetchLatestTransactions = useCallback(async () => {
+ try {
+ setIsLoading(true);
+
+ // First, get the latest block number
+ const blockNumberResponse = await fetch(
+ `https://api.etherscan.io/api?module=proxy&action=eth_blockNumber&apikey=${ETHERSCAN_API_KEY}`
+ );
+
+ if (!blockNumberResponse.ok) {
+ throw new Error('Failed to fetch latest block number');
+ }
+
+ const blockNumberData = await blockNumberResponse.json();
+ if (blockNumberData.error) {
+ throw new Error(blockNumberData.error.message);
+ }
+
+ const latestBlock = parseInt(blockNumberData.result, 16);
+
+ // Then, get the transactions from the latest block
+ const response = await fetch(
+ `https://api.etherscan.io/api?module=proxy&action=eth_getBlockByNumber&tag=latest&boolean=true&apikey=${ETHERSCAN_API_KEY}`
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch block transactions');
+ }
+
+ const data = await response.json();
+ if (data.error) {
+ throw new Error(data.error.message);
+ }
+
+ if (data.result && data.result.transactions) {
+ const formattedTransactions = await Promise.all(
+ data.result.transactions.slice(0, 50).map(async (tx: any) => {
+ const timestamp = parseInt(data.result.timestamp, 16);
+ return {
+ hash: tx.hash,
+ method: getTransactionMethod(tx.input),
+ block: parseInt(tx.blockNumber, 16).toString(),
+ age: getRelativeTime(timestamp),
+ from: tx.from,
+ to: tx.to || 'Contract Creation',
+ amount: utils.formatEther(tx.value) + ' ETH',
+ fee: utils.formatEther(BigInt(tx.gas) * BigInt(tx.gasPrice)),
+ timestamp: timestamp
+ };
+ })
+ );
+ setTransactions(formattedTransactions);
+ }
+ } catch (error) {
+ console.error('Error fetching transactions:', error);
+ toast({
+ title: "Error fetching transactions",
+ description: error instanceof Error ? error.message : "Failed to fetch latest transactions.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [ETHERSCAN_API_KEY]);
+
+ useEffect(() => {
+ fetchLatestTransactions();
+ const interval = setInterval(fetchLatestTransactions, 15000); // Refresh every 15 seconds
+ return () => clearInterval(interval);
+ }, [fetchLatestTransactions, currentPage]);
+
+ // Effect to handle responsive design
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+ handleResize();
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ // Utility functions (handleDownload, copyToClipboard, etc.)
+ const copyToClipboard = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast({
+ title: "Copied to clipboard",
+ description: "The text has been copied to your clipboard.",
+ });
+ } catch (err) {
+ console.error('Failed to copy: ', err);
+ toast({
+ title: "Failed to copy",
+ description: "An error occurred while copying the text.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ const handleDownload = () => {
+ const headers = ['Transaction Hash', 'Method', 'Block', 'Age', 'From', 'To', 'Amount', 'Txn Fee'];
+ const csvContent = [
+ headers.join(','),
+ ...transactions.map(tx =>
+ [
+ tx.hash,
+ tx.method,
+ tx.block,
+ formatTimestamp(tx.timestamp),
+ tx.from,
+ tx.to,
+ tx.amount,
+ tx.fee,
+ ].join(',')
+ )
+ ].join('\n');
+
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+ const link = document.createElement('a');
+ if (link.download !== undefined) {
+ const url = URL.createObjectURL(blob);
+ link.setAttribute('href', url);
+ link.setAttribute('download', 'ethereum_transactions.csv');
+ link.style.visibility = 'hidden';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ };
+
+ const formatTimestamp = (timestamp: number): string => {
+ const date = new Date(timestamp * 1000);
+ const options: Intl.DateTimeFormatOptions = {
+ timeZone: 'Asia/Bangkok',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ };
+ return date.toLocaleString('en-GB', options).replace(',', '');
+ };
+
+ const handleMethodClick = (method: string) => {
+ setSelectedMethod(method === selectedMethod ? null : method);
+ };
+
+ const scrollToTop = useCallback(() => {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }, []);
+
+ return (
+
+
+ {/* Transaction table header */}
+
+
+
Latest {transactions.length} transactions
+
(Auto-updating)
+
+
+
+
+
+ {!isMobile && "Download Data"}
+
+
setCurrentPage(1)}
+ disabled={currentPage === 1}
+ >
+ First
+
+
setCurrentPage(currentPage - 1)}
+ disabled={currentPage === 1}
+ >
+
+
+
+ Page {currentPage} of {totalPages}
+
+
setCurrentPage(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ >
+
+
+
setCurrentPage(totalPages)}
+ disabled={currentPage === totalPages}
+ >
+ Last
+
+
+
+
+ {/* Transaction table */}
+
+
+
+
+
+ Transaction Hash
+ Method
+ Block
+ Age
+ From
+ To
+ Amount
+ Txn Fee
+
+
+
+ {isLoading ? (
+
+
+ Loading transactions...
+
+
+ ) : (
+ transactions.map((tx, index) => (
+
+
+
+
+
+
+
+
+
+
+ {truncateAddress(tx.hash)}
+
+
+ copyToClipboard(tx.hash)}
+ className="h-5 w-5 p-0"
+ >
+
+
+
+
+
+ handleMethodClick(tx.method)}
+ className={`px-3 py-1 rounded-full text-base font-medium w-25 h-10 flex items-center justify-center ${
+ selectedMethod === tx.method
+ ? 'bg-[#F5B056] text-white border-2 border-[#F5B056]'
+ : 'bg-gray-800 text-gray-300 border border-gray-700 hover:border-[#F5B056] transition-all duration-300'
+ }`}
+ >
+ {tx.method}
+
+
+ {tx.block}
+ {tx.age}
+
+
+
+
+ {truncateAddress(tx.from)}
+
+
+ copyToClipboard(tx.from)}
+ className="h-5 w-5 p-0"
+ >
+
+
+
+
+
+
+
+
+ {truncateAddress(tx.to)}
+
+
+ copyToClipboard(tx.to)}
+ className="h-5 w-5 p-0"
+ >
+
+
+
+
+ {formatAmount(tx.amount)}
+ {formatFee(tx.fee)}
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination controls (bottom) */}
+
+
+
+
setCurrentPage(1)}
+ disabled={currentPage === 1}
+ >
+ First
+
+
setCurrentPage(currentPage - 1)}
+ disabled={currentPage === 1}
+ >
+
+
+
+ Page {currentPage} of {totalPages}
+
+
setCurrentPage(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ >
+
+
+
setCurrentPage(totalPages)}
+ disabled={currentPage === totalPages}
+ >
+ Last
+
+
+
+
+ {/* Info text */}
+
+ A transaction is a cryptographically signed instruction that changes the blockchain state.
+ Block explorers track the details of all transactions in the network.
+
+
+ {/* Back to top button */}
+
+ Back to Top
+
+
+
+ );
+}
+
+// Utility functions
+const formatAmount = (amount: string) => {
+ if (!amount) return '0 ETH';
+ const value = parseFloat(amount);
+ return `${value.toFixed(6)} ETH`;
+};
+
+const formatFee = (fee: string) => {
+ if (!fee) return '0';
+ const value = parseFloat(fee);
+ return value.toFixed(6);
+};
\ No newline at end of file
diff --git a/components/transactions/TransactionSection.tsx b/components/transactions/TransactionSection.tsx
new file mode 100644
index 0000000..c687f6a
--- /dev/null
+++ b/components/transactions/TransactionSection.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import { Card, CardHeader, CardTitle } from "@/components/ui/card";
+import NetworkTransactionTable from './NetworkTransactionTable';
+import { CoinOption } from "@/services/cryptoService";
+import { motion } from "framer-motion";
+
+interface TransactionSectionProps {
+ selectedCoin: CoinOption | null;
+}
+
+export default function TransactionSection({ selectedCoin }: TransactionSectionProps) {
+ return (
+
+
+
+ Transaction History
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/transactions/WalletCharts.tsx b/components/transactions/WalletCharts.tsx
new file mode 100644
index 0000000..c95aa83
--- /dev/null
+++ b/components/transactions/WalletCharts.tsx
@@ -0,0 +1,213 @@
+'use client';
+
+import { useEffect, useState } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Line, LineChart, BarChart, Bar, ResponsiveContainer, Tooltip, XAxis, YAxis, Area, AreaChart, PieChart, Pie, Cell, RadialBarChart, RadialBar } from "recharts";
+import { Activity, Wallet, TrendingUp, Database, Cpu } from "lucide-react";
+import { BlockchainMetrics, GlobalMetrics, fetchBlockchainMetrics, fetchGlobalMetrics } from "@/services/cryptoService";
+import { Loader2, AlertCircle } from "lucide-react";
+
+const COLORS = {
+ primary: '#F5B056',
+ secondary: '#3b82f6',
+ tertiary: '#22c55e',
+ quaternary: '#a855f7',
+ error: '#ef4444',
+ gray: '#666',
+};
+
+export default function WalletCharts() {
+ const [blockchainMetrics, setBlockchainMetrics] = useState(null);
+ const [globalMetrics, setGlobalMetrics] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const [blockData, globalData] = await Promise.all([
+ fetchBlockchainMetrics(),
+ fetchGlobalMetrics()
+ ]);
+ setBlockchainMetrics(blockData);
+ setGlobalMetrics(globalData);
+ } catch (err) {
+ console.error('Error fetching metrics:', err);
+ setError('Failed to fetch metrics');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ const interval = setInterval(fetchData, 30000); // Update every 30 seconds
+
+ return () => clearInterval(interval);
+ }, []);
+
+ if (loading) {
+ return (
+
+
+ {[1, 2].map((i) => (
+
+
+
+
+
+ ))}
+
+
+ );
+ }
+
+ if (error || !blockchainMetrics || !globalMetrics) {
+ return (
+
+ Error loading metrics. Please try again later.
+
+ );
+ }
+
+ const formatNumber = (num: number): string => {
+ if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
+ if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
+ if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
+ if (num >= 1e3) return `${(num / 1e3).toFixed(2)}K`;
+ return num.toFixed(2);
+ };
+
+ // Prepare data for visualizations
+ const networkHealthData = [
+ { name: 'Block Time', value: (blockchainMetrics.avgBlockTime / 15) * 100, fill: COLORS.primary },
+ { name: 'Gas Price', value: (blockchainMetrics.gasPrice / 50) * 100, fill: COLORS.secondary },
+ { name: 'Validators', value: (blockchainMetrics.activeValidators / 600000) * 100, fill: COLORS.tertiary },
+ { name: 'Staking', value: (blockchainMetrics.stakingAPR / 10) * 100, fill: COLORS.quaternary }
+ ];
+
+ const marketShareData = Object.entries(globalMetrics.market_cap_percentage || {})
+ .slice(0, 5)
+ .map(([name, value], index) => ({
+ name: name.toUpperCase(),
+ value,
+ fill: Object.values(COLORS)[index]
+ }));
+
+ return (
+
+
+ {/* Market Share Chart */}
+
+
+
+
+ Market Share
+
+
+
+
+
+
+
+ {marketShareData.map((entry, index) => (
+ |
+ ))}
+
+ [`${value.toFixed(2)}%`]}
+ labelStyle={{ color: '#fff' }}
+ itemStyle={{ color: '#fff' }}
+ />
+
+
+
+
+ {marketShareData.map((entry) => (
+
+ ))}
+
+
+
+
+ {/* Stats Grid */}
+
+
+
+
+
+ Market Cap
+
+
+
+
+ ${formatNumber(globalMetrics.total_market_cap.usd)}
+
+
+
+
+
+
+
+
+
+
+ ${formatNumber(globalMetrics.total_volume.usd)}
+
+
+
+
+
+
+
+
+ Active Coins
+
+
+
+
+ {globalMetrics.active_cryptocurrencies.toLocaleString()}
+
+
+
+
+
+
+
+
+ Markets
+
+
+
+
+ {globalMetrics.markets.toLocaleString()}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/NetworkStats.tsx b/components/ui/NetworkStats.tsx
deleted file mode 100644
index 4b8aecb..0000000
--- a/components/ui/NetworkStats.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-'use client'
-
-import { useState, useEffect} from 'react'
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import axios from 'axios';
-import TransactionTable from '@/components/ui/TransactionTable';
-
-interface Stats {
- transactions24h: number;
- pendingTransactions: number;
- networkFee: number;
- avgGasFee: number;
- totalTransactionAmount: number; // New field for total transaction amount
-}
-
-// Initial state
-const initialStats: Stats = {
- transactions24h: 0,
- pendingTransactions: 0,
- networkFee: 0,
- avgGasFee: 0,
- totalTransactionAmount: 0, // Initialize to 0
-};
-
-export default function TransactionExplorer() {
- // State variables
- const [, setIsMobile] = useState(false);
- const [stats, setStats] = useState(initialStats);
- const [, setTotalTransactions] = useState(0);
- const [, setLoading] = useState(true);
- const [, setError] = useState(null);
-
-
- // Etherscan API configuration
- const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY; // Replace with your API key
- const API_URL = `/api/etherscan?module=proxy&action=eth_blockNumber`;
-
- // Fetch network statistics
- const fetchNetworkStats = async () => {
- try {
- // Get gas price statistics
- const gasResponse = await fetch(
- `https://api.etherscan.io/api?module=gastracker&action=gasoracle&apikey=${ETHERSCAN_API_KEY}`
- );
- const gasData = await gasResponse.json();
-
- if (gasData.status === "1") {
- setStats(prev => ({
- ...prev,
- networkFee: parseFloat(gasData.result.SafeGasPrice),
- avgGasFee: parseFloat(gasData.result.ProposeGasPrice)
- }));
- }
-
- // Get 24h transaction count (approximate)
- const blockResponse = await fetch(
- `https://api.etherscan.io/api?module=proxy&action=eth_blockNumber&apikey=${ETHERSCAN_API_KEY}`
- );
- const blockData = await blockResponse.json();
- const latestBlock = parseInt(blockData.result, 16);
-
- // Assuming ~15 second block time, calculate blocks in 24h
- const blocksIn24h = Math.floor(86400 / 15);
-
- // Get transaction count for latest block
- const txCountResponse = await fetch(
- `https://api.etherscan.io/api?module=proxy&action=eth_getBlockTransactionCountByNumber&tag=${latestBlock.toString(16)}&apikey=${ETHERSCAN_API_KEY}`
- );
- const txCountData = await txCountResponse.json();
- const txCount = parseInt(txCountData.result, 16);
-
- setStats(prev => ({
- ...prev,
- transactions24h: txCount * blocksIn24h, // Rough estimation
- pendingTransactions: txCount // Current block's transaction count as pending
- }));
- } catch (error) {
- console.error('Error fetching network stats:', error);
- }
- };
-
- const fetchTotalTransactions = async () => {
- setLoading(true); // Đặt loading thành true trước khi gọi API
- try {
- const response = await axios.get(API_URL);
- const totalTxCount = response.data.result; // Giả định bạn có cách lấy số giao dịch từ API
-
- setTotalTransactions(Number(totalTxCount));
- } catch (err) {
- setError('Lỗi khi lấy dữ liệu từ API');
- } finally {
- setLoading(false);
- }
-};
-
-useEffect(() => {
- fetchTotalTransactions();
- const interval = setInterval(() => {
- fetchTotalTransactions();
- }, 300000);
-
- return () => clearInterval(interval);
-}, []);
-
-
- useEffect(() => {
- fetchNetworkStats();
- const interval = setInterval(() => {
- fetchNetworkStats();
- }, 30000); // Refresh every 5 minutes
-
- return () => clearInterval(interval);
- }, []);
-
-
- // Effect to handle responsive design
- useEffect(() => {
- const handleResize = () => {
- setIsMobile(window.innerWidth < 768);
- };
- handleResize();
- window.addEventListener('resize', handleResize);
- return () => window.removeEventListener('resize', handleResize);
- }, []);
-
-
- return (
-
-
- {/* Statistics cards */}
-
-
- Transactions (24h)
-
-
-
- {stats.transactions24h.toLocaleString()}
-
-
-
-
-
-
- Pending Txns
-
-
- {stats.pendingTransactions.toLocaleString()}
-
-
-
-
-
- Network Fee
-
-
- {stats.networkFee.toFixed(2)} Gwei
-
-
-
-
-
- AVG Gas Fee
-
-
- {stats.avgGasFee.toFixed(2)} Gwei
-
-
-
-
-
-
-
- );
-}
-
-
-
\ No newline at end of file
diff --git a/components/ui/TransactionTable.tsx b/components/ui/TransactionTable.tsx
index 90bce34..181dee9 100644
--- a/components/ui/TransactionTable.tsx
+++ b/components/ui/TransactionTable.tsx
@@ -1,511 +1,205 @@
-'use client'
+"use client"
-import { useState, useEffect, useCallback } from 'react'
+import { useSearchParams } from "next/navigation"
+import { useEffect, useState } from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
-import Link from 'next/link'
-import { Eye, ChevronLeft, ChevronRight, Download, Copy } from 'lucide-react'
-import { toast } from "@/components/ui/use-toast"
-import { ethers } from 'ethers';
-import { formatEther } from 'ethers/lib/utils';
-
-
-interface Stats {
- transactions24h: number;
- pendingTransactions: number;
- networkFee: number;
- avgGasFee: number;
- totalTransactionAmount: number; // New field for total transaction amount
+import { Loader2 } from "lucide-react"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+
+interface Transaction {
+ id: string
+ from: string
+ to: string
+ value: string
+ timestamp: string
+ type: "transfer" | "swap" | "inflow" | "outflow"
}
-// Initial state
-const initialStats: Stats = {
- transactions24h: 0,
- pendingTransactions: 0,
- networkFee: 0,
- avgGasFee: 0,
- totalTransactionAmount: 0, // Initialize to 0
-};
-
-export default function TransactionExplorer() {
- // State variables
- const [transactions, setTransactions] = useState([]);
- const [currentPage, setCurrentPage] = useState(1);
- const [selectedMethod, setSelectedMethod] = useState(null);
- const [totalPages] = useState(5000);
- const [isMobile, setIsMobile] = useState(false);
- const [isLoading, setIsLoading] = useState(false);
-
-
- // Etherscan API configuration
- const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY; // Replace with your API key
- const API_URL = `https://api.etherscan.io/api?module=proxy&action=eth_blockNumber&apikey=${ETHERSCAN_API_KEY}`;
-
- interface MethodSignatures {
- [key: string]: string;
- }
-
- const knownMethods: MethodSignatures = {
- '0xa9059cbb': 'Transfer',
- '0x23b872dd': 'TransferFrom',
- '0x095ea7b3': 'Approve',
- '0x70a08231': 'BalanceOf',
- '0x18160ddd': 'TotalSupply',
- '0x313ce567': 'Decimals',
- '0x06fdde03': 'Name',
- '0x95d89b41': 'Symbol',
- '0xd0e30db0': 'Deposit',
- '0x2e1a7d4d': 'Withdraw',
- '0x3593564c': 'Execute',
- '0x4a25d94a': 'SwapExactTokensForTokens',
- '0x7ff36ab5': 'SwapExactETHForTokens',
- '0x791ac947': 'SwapExactTokensForETH',
- '0xfb3bdb41': 'SwapETHForExactTokens',
- '0x5c11d795': 'SwapTokensForExactTokens',
- '0xb6f9de95': 'Claim',
- '0x6a627842': 'Mint',
- '0xa0712d68': 'Mint',
- };
-
- const getTransactionMethod = (input: string): string => {
- if (input === '0x') return 'Transfer';
-
- const functionSelector = input.slice(0, 10).toLowerCase();
-
- if (knownMethods[functionSelector]) {
- return knownMethods[functionSelector];
- }
-
- return 'Swap';
- };
-
- // Function to get relative time
-const getRelativeTime = (timestamp: number) => {
- const now = Date.now();
- const diff = now - timestamp * 1000;
-
- // Ensure diff is not negative
- if (diff < 0) return "Just now";
-
- const seconds = Math.floor(diff / 1000);
-
- if (seconds < 60) return `${seconds} secs ago`;
- const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return `${minutes} mins ago`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours} hrs ago`;
- const days = Math.floor(hours / 24);
- return `${days} days ago`;
-};
-
- // Function to truncate addresses
- const truncateAddress = (address: string) => {
- return `${address.slice(0, 6)}...${address.slice(-4)}`;
- };
-
- // Fetch latest blocks and their transactions
- const fetchLatestTransactions = useCallback(async () => {
- if (!ETHERSCAN_API_KEY) {
- console.error('Etherscan API key is not set')
- return
- }
-
- try {
- setIsLoading(true)
- const latestBlockResponse = await fetch(API_URL)
- const latestBlockData = await latestBlockResponse.json()
- const latestBlock = parseInt(latestBlockData.result, 16)
-
- const response = await fetch(
- `https://api.etherscan.io/api?module=proxy&action=eth_getBlockByNumber&tag=latest&boolean=true&apikey=${ETHERSCAN_API_KEY}`
- )
- const data = await response.json()
-
- if (data.result && data.result.transactions) {
- const formattedTransactions = await Promise.all(
- data.result.transactions.slice(0, 50).map(async (tx: any) => {
- const timestamp = parseInt(data.result.timestamp, 16)
- return {
- hash: tx.hash,
- method: getTransactionMethod(tx.input),
- block: parseInt(tx.blockNumber, 16).toString(),
- age: getRelativeTime(timestamp),
- from: tx.from,
- to: tx.to || 'Contract Creation',
- amount: formatEther(tx.value) + ' ETH',
- fee: formatEther(BigInt(tx.gas) * BigInt(tx.gasPrice)),
- timestamp: timestamp
- }
- })
- )
- setTransactions(formattedTransactions)
- }
- } catch (error) {
- console.error('Error fetching transactions:', error)
- toast({
- title: "Error fetching transactions",
- description: "Failed to fetch latest transactions.",
- variant: "destructive",
- })
- } finally {
- setIsLoading(false)
- }
- }, [toast])
+export default function TransactionTable() {
+ const searchParams = useSearchParams()
+ const address = searchParams.get("address")
+ const [transactions, setTransactions] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [page, setPage] = useState(1)
useEffect(() => {
- fetchLatestTransactions()
- const interval = setInterval(fetchLatestTransactions, 150000) // Refresh every 2.5 minutes
- return () => clearInterval(interval)
- }, [fetchLatestTransactions])
-
-
- // Effect to fetch data
- useEffect(() => {
- fetchLatestTransactions();
- const interval = setInterval(() => {
- fetchLatestTransactions();
- }, 150000); // Refresh every 2.5 minutes
-
- return () => clearInterval(interval);
- }, [currentPage]); //Refresh every page changes
-
- // Effect to handle responsive design
- useEffect(() => {
- const handleResize = () => {
- setIsMobile(window.innerWidth < 768);
- };
- handleResize();
- window.addEventListener('resize', handleResize);
- return () => window.removeEventListener('resize', handleResize);
- }, []);
-
-
- // Utility functions (handleDownload, copyToClipboard, etc.)
- const copyToClipboard = async (text: string) => {
- try {
- await navigator.clipboard.writeText(text);
- toast({
- title: "Copied to clipboard",
- description: "The text has been copied to your clipboard.",
- });
- } catch (err) {
- console.error('Failed to copy: ', err);
- toast({
- title: "Failed to copy",
- description: "An error occurred while copying the text.",
- variant: "destructive",
- });
- }
- };
-
- const handleDownload = () => {
- const headers = ['Transaction Hash', 'Method', 'Block', 'Age', 'From', 'To', 'Amount', 'Txn Fee'];
- const csvContent = [
- headers.join(','),
- ...transactions.map(tx =>
- [
- tx.hash,
- tx.method,
- tx.block,
- formatTimestamp(tx.timestamp),
- tx.from,
- tx.to,
- tx.amount,
- tx.fee,
- ].join(',')
- )
- ].join('\n');
-
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
- const link = document.createElement('a');
- if (link.download !== undefined) {
- const url = URL.createObjectURL(blob);
- link.setAttribute('href', url);
- link.setAttribute('download', 'ethereum_transactions.csv');
- link.style.visibility = 'hidden';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
+ if (address) {
+ setLoading(true)
+ setError(null)
+ fetch(`/api/transactions?address=${address}&page=${page}&offset=20`)
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.error) {
+ throw new Error(data.error)
+ }
+ // Mock categorization of transactions
+ const categorizedData = data.map((tx: Transaction) => ({
+ ...tx,
+ type: categorizeTransaction(tx, address),
+ }))
+ setTransactions(categorizedData)
+ })
+ .catch((err) => {
+ console.error("Error fetching transactions:", err)
+ setError(err.message || "Failed to fetch transactions")
+ })
+ .finally(() => setLoading(false))
}
- };
+ }, [address, page])
- const formatTimestamp = (timestamp: number): string => {
- // Create a date object from the timestamp
- const date = new Date(timestamp * 1000); // Convert seconds to milliseconds
+ const categorizeTransaction = (tx: Transaction, userAddress: string): Transaction["type"] => {
+ if (tx.from === userAddress && tx.to === userAddress) return "swap"
+ if (tx.from === userAddress) return "outflow"
+ if (tx.to === userAddress) return "inflow"
+ return "transfer"
+ }
- // Convert to GMT+7
- const options: Intl.DateTimeFormatOptions = {
- timeZone: 'Asia/Bangkok', // GMT+7 timezone
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hour12: false // Use 24-hour format
- };
+ if (loading) {
+ return (
+
+
+
+
+
+ )
+ }
- // Format the date
- return date.toLocaleString('en-GB', options).replace(',', ''); // Remove comma for better CSV formatting
-};
+ if (error) {
+ return (
+
+ Error
+ {error}
+
+ )
+ }
- const handleMethodClick = (method: string) => {
- setSelectedMethod(method === selectedMethod ? null : method);
- };
+ if (transactions.length === 0) {
+ return (
+
+ No transactions found.
+
+ )
+ }
- const scrollToTop = useCallback(() => {
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }, []);
+ const renderTransactionTable = (transactions: Transaction[]) => (
+
+
+
+ From
+ To
+ Value
+ Timestamp
+
+
+
+ {transactions.map((tx) => (
+
+
+ {tx.from.slice(0, 6)}...{tx.from.slice(-4)}
+
+
+ {tx.to.slice(0, 6)}...{tx.to.slice(-4)}
+
+ {tx.value}
+ {new Date(tx.timestamp).toLocaleString()}
+
+ ))}
+
+
+ )
return (
-
-
-
-
- {/* Transaction table header */}
-
-
-
Latest {transactions.length} transactions
-
(Auto-updating)
-
-
-
-
+
+ Recent Transactions
+
+
+
+
+
-
- {!isMobile && "Download Data"}
-
-
setCurrentPage(1)}
- disabled={currentPage === 1}
+ All
+
+
- First
-
-
setCurrentPage(currentPage - 1)}
- disabled={currentPage === 1}
+ Transfer
+
+
-
-
-
- Page {currentPage} of {totalPages}
-
-
setCurrentPage(currentPage + 1)}
- disabled={currentPage === totalPages}
+ Swap
+
+
-
-
-
setCurrentPage(totalPages)}
- disabled={currentPage === totalPages}
+ Inflow
+
+
- Last
-
-
-
-
-
-{/* Transaction table */}
-
-
-
-
-
- Transaction Hash
- Method
- Block
- Age
- From
- To
- Amount
- Txn Fee
-
-
-
- {isLoading ? (
-
-
- Loading transactions...
-
-
- ) : (
- transactions.map((tx, index) => (
-
-
-
-
-
-
-
-
-
-
- {truncateAddress(tx.hash)}
-
-
- copyToClipboard(tx.hash)}
- className="h-5 w-5 p-0"
- >
-
-
-
-
-
- handleMethodClick(tx.method)}
- className={`px-3 py-1 rounded-full text-base font-medium w-25 h-10 flex items-center justify-center ${
- selectedMethod === tx.method
- ? 'bg-[#F5B056] text-white border-2 border-[#F5B056]'
- : 'bg-gray-800 text-gray-300 border border-gray-700 hover:border-[#F5B056] transition-all duration-300'
- }`}
- >
- {tx.method}
-
-
- {tx.block}
- {tx.age}
-
-
-
-
- {truncateAddress(tx.from)}
-
-
- copyToClipboard(tx.from)}
- className="h-5 w-5 p-0"
- >
-
-
-
-
-
-
-
-
- {truncateAddress(tx.to)}
-
-
- copyToClipboard(tx.to)}
- className="h-5 w-5 p-0"
- >
-
-
-
-
- {formatAmount(tx.amount)}
- {formatFee(tx.fee)}
-
- ))
- )}
-
-
-
-
- {/* Pagination controls (bottom) */}
-
-
-
+ Outflow
+
+
+
{renderTransactionTable(transactions)}
+
+ {renderTransactionTable(transactions.filter((tx) => tx.type === "transfer"))}
+
+
+ {renderTransactionTable(transactions.filter((tx) => tx.type === "swap"))}
+
+
+ {renderTransactionTable(transactions.filter((tx) => tx.type === "inflow"))}
+
+
+ {renderTransactionTable(transactions.filter((tx) => tx.type === "outflow"))}
+
+
+
setCurrentPage(1)}
- disabled={currentPage === 1}
- >
- First
-
-
setCurrentPage(currentPage - 1)}
- disabled={currentPage === 1}
- >
-
-
-
- Page {currentPage} of {totalPages}
-
-
setCurrentPage(currentPage + 1)}
- disabled={currentPage === totalPages}
- >
-
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ className="bg-[#F5B056] text-white px-6 py-2 rounded-lg font-medium
+ hover:bg-[#E69A45]
+ disabled:bg-gray-400 disabled:text-gray-600 disabled:cursor-not-allowed"
+ >
+ Previous
setCurrentPage(totalPages)}
- disabled={currentPage === totalPages}
- >
- Last
+ onClick={() => setPage((p) => p + 1)}
+ disabled={transactions.length < 20}
+ className="bg-[#F5B056] text-white px-6 py-2 rounded-lg font-medium
+ hover:bg-[#E69A45]
+ disabled:bg-gray-400 disabled:text-gray-600 disabled:cursor-not-allowed"
+ >
+ Next
-
-
- {/* Info text */}
-
- A transaction is a cryptographically signed instruction that changes the blockchain state.
- Block explorers track the details of all transactions in the network.
-
-
- {/* Back to top button */}
-
- Back to Top
-
-
-
- );
-}
-
-// Utility functions
-const formatAmount = (amount: string) => {
- if (!amount) return '0 ETH';
- const value = parseFloat(amount);
- return `${value.toFixed(6)} ETH`;
-};
-
-const formatFee = (fee: string) => {
- if (!fee) return '0';
- const value = parseFloat(fee);
- return value.toFixed(6);
-};
-
-
-
-
\ No newline at end of file
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
index f000e3e..6e29c70 100644
--- a/components/ui/badge.tsx
+++ b/components/ui/badge.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
- "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ "inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx
index 9fa4894..b60be65 100644
--- a/components/ui/collapsible.tsx
+++ b/components/ui/collapsible.tsx
@@ -1,5 +1,6 @@
"use client"
+import * as React from "react"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
diff --git a/components/ui/debug-badge.tsx b/components/ui/debug-badge.tsx
new file mode 100644
index 0000000..8db4ee5
--- /dev/null
+++ b/components/ui/debug-badge.tsx
@@ -0,0 +1,184 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { ExternalLink, X, Copy, AlertTriangle, CheckCircle } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+
+interface HostnameError {
+ hostname: string
+ timestamp: number
+}
+
+interface DebugBadgeProps {
+ className?: string
+ position?: 'top-right' | 'bottom-right' | 'bottom-left' | 'top-left'
+}
+
+export function DebugBadge({
+ className,
+ position = 'bottom-right'
+}: DebugBadgeProps) {
+ const [isClient, setIsClient] = useState(false)
+ const [isOpen, setIsOpen] = useState(true)
+ const [isCollapsed, setIsCollapsed] = useState(false)
+ const [hostnameErrors, setHostnameErrors] = useState
([])
+ const [copied, setCopied] = useState(false)
+
+ // Only run in development and client-side
+ const isDevelopment = process.env.NODE_ENV === 'development'
+
+ useEffect(() => {
+ setIsClient(true)
+
+ // Create a custom event listener for image hostname errors
+ const handleHostnameError = (event: CustomEvent<{ hostname: string, timestamp: number }>) => {
+ const { hostname, timestamp } = event.detail
+
+ if (hostname) {
+ setHostnameErrors(prev => {
+ // Don't add duplicates
+ if (prev.some(item => item.hostname === hostname)) {
+ return prev
+ }
+ return [...prev, { hostname, timestamp: timestamp || Date.now() }]
+ })
+ }
+ }
+
+ // Add event listener
+ window.addEventListener(
+ 'nextImageHostnameError',
+ handleHostnameError as EventListener
+ )
+
+ return () => {
+ window.removeEventListener(
+ 'nextImageHostnameError',
+ handleHostnameError as EventListener
+ )
+ }
+ }, [])
+
+ const copyConfigToClipboard = () => {
+ const configText = hostnameErrors.map(({ hostname }) => (
+ `{\n protocol: "https",\n hostname: "${hostname}",\n pathname: "/**",\n},`
+ )).join('\n')
+
+ navigator.clipboard.writeText(configText).then(() => {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ })
+ }
+
+ // Only show in development and when there are errors
+ if (!isClient || !isDevelopment || !isOpen || hostnameErrors.length === 0) {
+ return null
+ }
+
+ const positionClasses = {
+ 'top-right': 'top-4 right-4',
+ 'bottom-right': 'bottom-4 right-4',
+ 'bottom-left': 'bottom-4 left-4',
+ 'top-left': 'top-4 left-4',
+ }
+
+ return (
+
+
+
+
+
+ {hostnameErrors.length} {hostnameErrors.length === 1 ? 'image host' : 'image hosts'} not configured
+
+
+
+ setIsCollapsed(!isCollapsed)}
+ className="text-amber-400 hover:text-amber-300 p-1"
+ title={isCollapsed ? "Expand" : "Collapse"}
+ >
+ {isCollapsed ? "+" : "-"}
+
+ setIsOpen(false)}
+ className="text-amber-400 hover:text-amber-300 p-1"
+ title="Dismiss"
+ >
+
+
+
+
+
+
setIsCollapsed(!open)}>
+
+
+
+ Add these hostnames to your next.config.js file:
+
+
+
+ {hostnameErrors.map(({hostname}, index) => (
+
+ {`{`}
+
+ protocol: "https",
+ hostname: "{hostname}",
+ pathname: "/**",
+
+ {`},`}
+ {index < hostnameErrors.length - 1 &&
}
+
+ ))}
+
+
+
+
+
+
+ Documentation
+
+
+
+
+ {copied ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy config
+ >
+ )}
+
+
+
+
+ Dev mode: Images still display without optimization. Fix before production.
+
+
+
+
+
+ )
+}
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
index 01ff19c..0d58a2e 100644
--- a/components/ui/dialog.tsx
+++ b/components/ui/dialog.tsx
@@ -10,9 +10,18 @@ const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
-const DialogPortal = DialogPrimitive.Portal
-
-const DialogClose = DialogPrimitive.Close
+const DialogPortal = ({
+ className,
+ children,
+ ...props
+}: DialogPrimitive.DialogPortalProps & { className?: string }) => (
+
+
+ {children}
+
+
+)
+DialogPortal.displayName = DialogPrimitive.Portal.displayName
const DialogOverlay = React.forwardRef<
React.ElementRef,
@@ -21,7 +30,7 @@ const DialogOverlay = React.forwardRef<
{children}
-
+
Close
@@ -110,9 +119,6 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
- DialogPortal,
- DialogOverlay,
- DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
diff --git a/components/ui/error-card.tsx b/components/ui/error-card.tsx
new file mode 100644
index 0000000..06c2a10
--- /dev/null
+++ b/components/ui/error-card.tsx
@@ -0,0 +1,112 @@
+"use client"
+
+import React from "react"
+import { Card, CardContent } from "@/components/ui/card"
+import { AlertOctagon, AlertTriangle, RefreshCw, Clock, Wifi, WifiOff } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+import { motion } from "framer-motion"
+
+export type ErrorType = "timeout" | "network" | "api" | "notFound" | "unknown"
+
+interface ErrorCardProps {
+ title?: string
+ message: string
+ type?: ErrorType
+ onRetry?: () => void
+ className?: string
+ suggestion?: string
+ timeout?: number // Timeout in seconds, if applicable
+}
+
+export function ErrorCard({
+ title,
+ message,
+ type = "unknown",
+ onRetry,
+ className,
+ suggestion,
+ timeout,
+}: ErrorCardProps) {
+ // Determine icon based on error type
+ const Icon = {
+ timeout: Clock,
+ network: WifiOff,
+ api: AlertOctagon,
+ notFound: AlertTriangle,
+ unknown: AlertTriangle,
+ }[type]
+
+ // Determine background gradient based on error type
+ const gradientClass = {
+ timeout: "from-amber-900/20 to-orange-900/20 border-amber-500/30",
+ network: "from-red-900/20 to-rose-900/20 border-red-500/30",
+ api: "from-indigo-900/20 to-blue-900/20 border-indigo-500/30",
+ notFound: "from-gray-900/20 to-slate-900/20 border-gray-500/30",
+ unknown: "from-violet-900/20 to-purple-900/20 border-violet-500/30",
+ }[type]
+
+ // Determine text color based on error type
+ const textColorClass = {
+ timeout: "text-amber-500",
+ network: "text-red-500",
+ api: "text-indigo-500",
+ notFound: "text-gray-400",
+ unknown: "text-violet-400",
+ }[type]
+
+ const defaultTitle = {
+ timeout: "Request Timed Out",
+ network: "Network Error",
+ api: "API Error",
+ notFound: "Not Found",
+ unknown: "Something Went Wrong",
+ }[type]
+
+ return (
+
+
+
+
+
+
+
+
{title || defaultTitle}
+
+ {onRetry && (
+
+
+ Retry
+
+ )}
+
+
+
+ {message}
+
+ {suggestion && (
+
+ Suggestion: {suggestion}
+
+ )}
+
+ {timeout && (
+
+
+ Request timed out after {timeout} seconds
+
+ )}
+
+
+ )
+}
diff --git a/components/ui/radio-group.tsx b/components/ui/radio-group.tsx
index e9bde17..168d0e7 100644
--- a/components/ui/radio-group.tsx
+++ b/components/ui/radio-group.tsx
@@ -1,3 +1,4 @@
+
"use client"
import * as React from "react"
diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx
index bc69cf2..5f4117f 100644
--- a/components/ui/switch.tsx
+++ b/components/ui/switch.tsx
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
index 30fc44d..caf6b9b 100644
--- a/components/ui/tooltip.tsx
+++ b/components/ui/tooltip.tsx
@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
- "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ "z-50 overflow-hidden rounded-md bg-gray-800 border border-amber-500/30 px-3 py-1.5 text-sm text-gray-200 shadow animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
diff --git a/lib/api/alchemyApi.ts b/lib/api/alchemyApi.ts
new file mode 100644
index 0000000..3234be5
--- /dev/null
+++ b/lib/api/alchemyApi.ts
@@ -0,0 +1,302 @@
+
+import axios from 'axios';
+
+const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY;
+
+interface AlchemyChain {
+ name: string;
+ chainId: string;
+ network: string;
+ baseUrl: string;
+ currency: string;
+ icon?: string;
+}
+
+export const SUPPORTED_CHAINS: AlchemyChain[] = [
+ {
+ name: 'Ethereum',
+ chainId: '1',
+ network: 'eth-mainnet',
+ baseUrl: `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`,
+ currency: 'ETH',
+ icon: '/icons/eth.svg'
+ },
+ {
+ name: 'Polygon',
+ chainId: '137',
+ network: 'polygon-mainnet',
+ baseUrl: `https://polygon-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`,
+ currency: 'MATIC',
+ icon: '/icons/matic.svg'
+ },
+ {
+ name: 'Arbitrum',
+ chainId: '42161',
+ network: 'arb-mainnet',
+ baseUrl: `https://arb-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`,
+ currency: 'ARB',
+ icon: '/icons/arb.svg'
+ },
+ {
+ name: 'Optimism',
+ chainId: '10',
+ network: 'opt-mainnet',
+ baseUrl: `https://opt-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`,
+ currency: 'OP',
+ icon: '/icons/op.svg'
+ },
+ {
+ name: 'Base',
+ chainId: '8453',
+ network: 'base-mainnet',
+ baseUrl: `https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`,
+ currency: 'ETH',
+ icon: '/icons/base.svg'
+ }
+];
+
+export interface TokenBalance {
+ contractAddress: string;
+ tokenBalance: string;
+ name?: string;
+ symbol?: string;
+ decimals?: number;
+ logo?: string;
+ usdPrice?: number;
+ usdValue?: number;
+ balanceFormatted?: string;
+ chain: string;
+ chainName: string;
+ chainIcon?: string;
+}
+
+const getTokenBalances = async (address: string, chain: AlchemyChain): Promise => {
+ try {
+ const response = await axios.post(chain.baseUrl, {
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'alchemy_getTokenBalances',
+ params: [address]
+ });
+
+ const tokenBalances = response.data.result.tokenBalances || [];
+
+ if (tokenBalances.length === 0) {
+ return [];
+ }
+
+ // Get token metadata
+ const metadataPromises = tokenBalances.map(async (token: any) => {
+ try {
+ const metadataResponse = await axios.post(chain.baseUrl, {
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'alchemy_getTokenMetadata',
+ params: [token.contractAddress]
+ });
+
+ return metadataResponse.data.result;
+ } catch (error) {
+ console.error(`Error fetching token metadata for ${token.contractAddress}:`, error);
+ return {
+ name: 'Unknown Token',
+ symbol: 'UNKNOWN',
+ decimals: 18,
+ logo: null
+ };
+ }
+ });
+
+ const metadataResults = await Promise.allSettled(metadataPromises);
+
+ return tokenBalances.map((token: any, index: number) => {
+ const metadata = metadataResults[index].status === 'fulfilled'
+ ? (metadataResults[index] as PromiseFulfilledResult).value
+ : { name: 'Unknown', symbol: 'UNKNOWN', decimals: 18 };
+
+ const decimals = metadata.decimals || 18;
+ const balanceFormatted = (parseInt(token.tokenBalance, 16) / Math.pow(10, decimals)).toString();
+
+ return {
+ contractAddress: token.contractAddress,
+ tokenBalance: token.tokenBalance,
+ name: metadata.name,
+ symbol: metadata.symbol,
+ decimals: metadata.decimals,
+ logo: metadata.logo,
+ balanceFormatted,
+ chain: chain.network,
+ chainName: chain.name,
+ chainIcon: chain.icon
+ };
+ });
+ } catch (error) {
+ console.error(`Error fetching token balances from Alchemy for ${chain.name}:`, error);
+ return [];
+ }
+};
+
+export const getNativeBalance = async (address: string, chain: AlchemyChain): Promise => {
+ try {
+ const response = await axios.post(chain.baseUrl, {
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'eth_getBalance',
+ params: [address, 'latest']
+ });
+
+ const balance = response.data.result;
+ const balanceFormatted = (parseInt(balance, 16) / 1e18).toString();
+
+ return {
+ contractAddress: 'native',
+ tokenBalance: balance,
+ name: chain.currency,
+ symbol: chain.currency,
+ decimals: 18,
+ balanceFormatted,
+ chain: chain.network,
+ chainName: chain.name,
+ chainIcon: chain.icon
+ };
+ } catch (error) {
+ console.error(`Error fetching native balance from Alchemy for ${chain.name}:`, error);
+ return null;
+ }
+};
+
+export const getWalletTokens = async (address: string, chains: AlchemyChain[] = SUPPORTED_CHAINS): Promise => {
+ try {
+ // Process chains in parallel for better performance
+ const promises = chains.flatMap(chain => [
+ getNativeBalance(address, chain),
+ getTokenBalances(address, chain)
+ ]);
+
+ const results = await Promise.allSettled(promises);
+
+ let allTokens: TokenBalance[] = [];
+
+ results.forEach(result => {
+ if (result.status === 'fulfilled') {
+ if (Array.isArray(result.value)) {
+ allTokens = [...allTokens, ...result.value];
+ } else if (result.value) {
+ allTokens.push(result.value);
+ }
+ }
+ });
+
+ return allTokens;
+ } catch (error) {
+ console.error('Error fetching wallet tokens from Alchemy:', error);
+ return [];
+ }
+};
+
+// Get token prices for ERC20 tokens using external price API
+export const enrichTokensWithPrices = async (tokens: TokenBalance[]): Promise => {
+ try {
+ // Group tokens by chain
+ const tokensByChain: Record = {};
+
+ tokens.forEach(token => {
+ if (token.contractAddress === 'native') return; // Skip native tokens (we'll handle them separately)
+
+ if (!tokensByChain[token.chain]) {
+ tokensByChain[token.chain] = [];
+ }
+
+ tokensByChain[token.chain].push(token.contractAddress);
+ });
+
+ // Get prices for tokens
+ const pricePromises = Object.entries(tokensByChain).map(async ([chain, addresses]) => {
+ try {
+ // Use CoinGecko or similar API to get prices
+ // This is a placeholder - you would need to implement an actual price API call
+ const response = await axios.get('https://api.coingecko.com/api/v3/simple/token_price', {
+ params: {
+ contract_addresses: addresses.join(','),
+ vs_currencies: 'usd',
+ include_market_cap: 'true',
+ include_24hr_vol: 'true',
+ include_24hr_change: 'true',
+ include_last_updated_at: 'true',
+ }
+ });
+
+ return {
+ chain,
+ prices: response.data
+ };
+ } catch (error) {
+ console.error(`Error fetching prices for chain ${chain}:`, error);
+ return {
+ chain,
+ prices: {}
+ };
+ }
+ });
+
+ const priceResults = await Promise.allSettled(pricePromises);
+
+ // Create a map of token prices
+ const priceMap: Record> = {};
+
+ priceResults.forEach(result => {
+ if (result.status === 'fulfilled') {
+ const { chain, prices } = result.value;
+ priceMap[chain] = prices;
+ }
+ });
+
+ // Enrich tokens with prices
+ return tokens.map(token => {
+ if (token.contractAddress === 'native') {
+ // Handle native token pricing separately (ETH, MATIC, etc.)
+ // This would be another API call to get the current price
+ return {
+ ...token,
+ usdPrice: 0, // Placeholder
+ usdValue: 0 // Placeholder
+ };
+ }
+
+ const price = priceMap[token.chain]?.[token.contractAddress.toLowerCase()]?.usd || 0;
+ const balance = parseFloat(token.balanceFormatted || '0');
+
+ return {
+ ...token,
+ usdPrice: price,
+ usdValue: balance * price
+ };
+ });
+ } catch (error) {
+ console.error('Error enriching tokens with prices:', error);
+ return tokens;
+ }
+};
+
+// Price API for native tokens
+export const getNativePrices = async (): Promise> => {
+ try {
+ const response = await axios.get('https://api.coingecko.com/api/v3/simple/price', {
+ params: {
+ ids: 'ethereum,polygon,arbitrum,optimism,base',
+ vs_currencies: 'usd'
+ }
+ });
+
+ return {
+ 'eth-mainnet': response.data.ethereum?.usd || 0,
+ 'polygon-mainnet': response.data.polygon?.usd || 0,
+ 'arb-mainnet': response.data.arbitrum?.usd || 0,
+ 'opt-mainnet': response.data.optimism?.usd || 0,
+ 'base-mainnet': response.data.base?.usd || 0
+ };
+ } catch (error) {
+ console.error('Error fetching native token prices:', error);
+ return {};
+ }
+};
diff --git a/lib/api/coinApi.ts b/lib/api/coinApi.ts
index 24f7547..b5cb099 100644
--- a/lib/api/coinApi.ts
+++ b/lib/api/coinApi.ts
@@ -1,14 +1,32 @@
-// lib/api/coinApi.ts
+
import { toast } from "sonner";
+import { supabase } from "@/src/integrations/supabase/client";
import { Coin, CoinDetail, CoinHistory } from "@/lib/types";
+const CACHE_EXPIRY = 60 * 1000; // 1 minute cache expiry
+
export const getCoins = async (page = 1, perPage = 20): Promise => {
+ const cacheKey = `coins_${page}_${perPage}`;
+
try {
+ // Try to get cached data
+ const { data: cachedData } = await supabase
+ .from('cached_coins')
+ .select('data, last_updated')
+ .eq('id', cacheKey)
+ .single();
+
+ // If cache is valid and not expired, return it
+ if (cachedData && Date.now() - new Date(cachedData.last_updated).getTime() < CACHE_EXPIRY) {
+ console.log('Returning cached coin data');
+ return cachedData.data as Coin[];
+ }
+
+ // Fetch fresh data from API
+ console.log(`Fetching fresh coin data for page ${page}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
- console.log(`Fetching coins for page ${page} with ${perPage} per page`);
-
const response = await fetch(
`https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=${perPage}&page=${page}&sparkline=true&price_change_percentage=1h,24h,7d&locale=en`,
{
@@ -23,12 +41,20 @@ export const getCoins = async (page = 1, perPage = 20): Promise => {
clearTimeout(timeoutId);
if (!response.ok) {
- console.error(`API request failed with status ${response.status}: ${response.statusText}`);
throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
- console.log(`Successfully fetched ${data.length} coins`);
+
+ // Update cache with new data
+ await supabase
+ .from('cached_coins')
+ .upsert({
+ id: cacheKey,
+ data: data,
+ last_updated: new Date().toISOString()
+ });
+
return data;
} catch (error) {
console.error("Error fetching coins:", error);
@@ -44,11 +70,25 @@ export const getCoinDetail = async (id: string): Promise => {
}
try {
+ // Try to get cached data
+ const { data: cachedData } = await supabase
+ .from('cached_coin_details')
+ .select('data, last_updated')
+ .eq('id', id)
+ .single();
+
+ // If cache is valid and not expired, return it
+ if (cachedData && Date.now() - new Date(cachedData.last_updated).getTime() < CACHE_EXPIRY) {
+ console.log('Returning cached coin detail');
+ return cachedData.data as CoinDetail;
+ }
+
+ // Fetch fresh data
+ console.log(`Fetching fresh data for coin: ${id}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const url = `https://api.coingecko.com/api/v3/coins/${id}?localization=false&tickers=false&market_data=true&community_data=false&developer_data=false&sparkline=true`;
- console.log(`Fetching data for coin with id: ${id}, URL: ${url}`);
const response = await fetch(url, {
signal: controller.signal,
@@ -62,27 +102,30 @@ export const getCoinDetail = async (id: string): Promise => {
clearTimeout(timeoutId);
if (!response.ok) {
- const errorText = await response.text(); // Lấy chi tiết lỗi từ server
- console.error(`API request failed with status ${response.status}: ${response.statusText} - ${errorText}`);
- throw new Error(`API request failed with status ${response.status}: ${response.statusText} - ${errorText}`);
+ throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
- console.log(`Successfully fetched data for coin: ${data.name}`);
+
+ // Update cache with new data
+ await supabase
+ .from('cached_coin_details')
+ .upsert({
+ id: id,
+ data: data,
+ last_updated: new Date().toISOString()
+ });
+
return data;
} catch (error) {
if (error instanceof Error) {
- if (error.name === "AbortError") {
- console.error(`Fetch aborted for coin ${id} due to timeout`);
- throw new Error("Request timed out after 30 seconds");
- }
- console.error(`Error fetching coin detail for ${id}: ${error.message}`);
+ console.error(`Error fetching coin detail for ${id}:`, error);
throw error;
}
- console.error(`Unknown error fetching coin detail for ${id}:`, error);
throw new Error("An unknown error occurred while fetching coin data");
}
};
+
export const getCoinHistory = async (
id: string,
days = 7
@@ -112,4 +155,4 @@ export const getCoinHistory = async (
total_volumes: Array.from({ length: 168 }, (_, i) => [Date.now() - (168 - i) * 3600000, 50000000000 + Math.random() * 10000000000]),
};
}
-};
\ No newline at end of file
+};
diff --git a/lib/api/globalApi.ts b/lib/api/globalApi.ts
index 666d302..e369b0e 100644
--- a/lib/api/globalApi.ts
+++ b/lib/api/globalApi.ts
@@ -1,9 +1,27 @@
-// lib/api/globalApi.ts
+
import { toast } from "sonner";
+import { supabase } from "@/src/integrations/supabase/client";
import { GlobalData } from "@/lib/types";
+const CACHE_EXPIRY = 60 * 1000; // 1 minute cache expiry
+
export const getGlobalData = async (): Promise => {
try {
+ // Try to get cached data
+ const { data: cachedData } = await supabase
+ .from('cached_global_data')
+ .select('data, last_updated')
+ .eq('id', 'global')
+ .single();
+
+ // If cache is valid and not expired, return it
+ if (cachedData && Date.now() - new Date(cachedData.last_updated).getTime() < CACHE_EXPIRY) {
+ console.log('Returning cached global data');
+ return cachedData.data as GlobalData;
+ }
+
+ // Fetch fresh data
+ console.log('Fetching fresh global data');
const response = await fetch("https://api.coingecko.com/api/v3/global", {
method: "GET",
headers: {
@@ -15,12 +33,24 @@ export const getGlobalData = async (): Promise => {
throw new Error(`API request failed with status ${response.status}`);
}
- const data = await response.json();
- return data.data;
+ const responseData = await response.json();
+ const data = responseData.data;
+
+ // Update cache with new data
+ await supabase
+ .from('cached_global_data')
+ .upsert({
+ id: 'global',
+ data: data,
+ last_updated: new Date().toISOString()
+ });
+
+ return data;
} catch (error) {
console.error("Error fetching global data:", error);
toast.error("Failed to load global market data");
+ // Return fallback data if fetch fails
return {
active_cryptocurrencies: 10000,
upcoming_icos: 0,
@@ -56,4 +86,4 @@ export const getGlobalData = async (): Promise => {
updated_at: Math.floor(Date.now() / 1000),
};
}
-};
\ No newline at end of file
+};
diff --git a/lib/api/moralisApi.ts b/lib/api/moralisApi.ts
new file mode 100644
index 0000000..4e7ac9d
--- /dev/null
+++ b/lib/api/moralisApi.ts
@@ -0,0 +1,212 @@
+import axios from 'axios';
+
+const MORALIS_API_KEY = process.env.MORALIS_API_KEY;
+const BASE_URL = 'https://deep-index.moralis.io/api/v2';
+
+interface MoralisChain {
+ chainId: string;
+ apiName: string;
+ name: string;
+ currency: string;
+ icon?: string;
+}
+
+export const SUPPORTED_CHAINS: MoralisChain[] = [
+ { chainId: '0x1', apiName: 'eth', name: 'Ethereum', currency: 'ETH', icon: '/icons/eth.svg' },
+ { chainId: '0x38', apiName: 'bsc', name: 'BNB Smart Chain', currency: 'BNB', icon: '/icons/bnb.svg' },
+ { chainId: '0x89', apiName: 'polygon', name: 'Polygon', currency: 'MATIC', icon: '/icons/matic.svg' },
+ { chainId: '0xa86a', apiName: 'avalanche', name: 'Avalanche', currency: 'AVAX', icon: '/icons/avax.svg' },
+ { chainId: '0xfa', apiName: 'fantom', name: 'Fantom', currency: 'FTM', icon: '/icons/ftm.svg' },
+ { chainId: '0xa4b1', apiName: 'arbitrum', name: 'Arbitrum', currency: 'ARB', icon: '/icons/arb.svg' },
+ { chainId: '0xa', apiName: 'optimism', name: 'Optimism', currency: 'OP', icon: '/icons/op.svg' },
+ { chainId: '0x2105', apiName: 'base', name: 'Base', currency: 'ETH', icon: '/icons/base.svg' },
+ { chainId: '0x14a33', apiName: 'base-goerli', name: 'Base Goerli', currency: 'ETH' },
+ { chainId: '0x144', apiName: 'cronos', name: 'Cronos', currency: 'CRO', icon: '/icons/cro.svg' },
+ { chainId: '0x64', apiName: 'gnosis', name: 'Gnosis', currency: 'XDAI', icon: '/icons/xdai.svg' },
+];
+
+export interface TokenBalance {
+ token_address: string;
+ name: string;
+ symbol: string;
+ logo?: string;
+ thumbnail?: string;
+ decimals: number;
+ balance: string;
+ usdPrice?: number;
+ usdValue?: number;
+ chain: string;
+ chainName: string;
+ chainIcon?: string;
+}
+
+export interface NativeBalance {
+ balance: string;
+ decimals: 18;
+ name: string;
+ symbol: string;
+ usdPrice?: number;
+ usdValue?: number;
+ chain: string;
+ chainName: string;
+ chainIcon?: string;
+}
+
+interface TokenMetadata {
+ address: string;
+ name: string;
+ symbol: string;
+ decimals: number;
+ logo?: string;
+ thumbnail?: string;
+ usdPrice?: number;
+}
+
+const instance = axios.create({
+ baseURL: BASE_URL,
+ headers: {
+ 'X-API-Key': MORALIS_API_KEY || '',
+ 'Accept': 'application/json'
+ }
+});
+
+// Add request/response interceptors for better debugging
+instance.interceptors.request.use(request => {
+ if (process.env.NODE_ENV === 'development') {
+ console.log('Moralis API Request:', request.url, request.params);
+ }
+ return request;
+});
+
+instance.interceptors.response.use(
+ response => response,
+ error => {
+ console.error('Moralis API Error:',
+ error.response?.data || error.message,
+ 'URL:', error.config?.url,
+ 'Status:', error.response?.status
+ );
+ return Promise.reject(error);
+ }
+);
+
+export const getTokenBalances = async (address: string, chain: MoralisChain): Promise => {
+ try {
+ const response = await instance.get(`/${address}/erc20`, {
+ params: {
+ chain: chain.apiName
+ }
+ });
+
+ const tokens = response.data;
+
+ // Fetch token prices if tokens exist
+ let tokenPrices: Record = {};
+ if (tokens.length > 0) {
+ const tokenAddresses = tokens.map((token: any) => token.token_address);
+ const priceResponse = await instance.get(`/erc20/prices`, {
+ params: {
+ chain: chain.apiName,
+ include: 'percent_change',
+ address: tokenAddresses.join(',')
+ }
+ });
+ tokenPrices = priceResponse.data?.tokenPrices?.reduce((acc: Record, curr: any) => {
+ acc[curr.address.toLowerCase()] = curr.usdPrice;
+ return acc;
+ }, {}) || {};
+ }
+
+ return tokens.map((token: any) => {
+ const usdPrice = tokenPrices[token.token_address.toLowerCase()] || 0;
+ const balance = token.balance / Math.pow(10, token.decimals);
+ const usdValue = balance * usdPrice;
+
+ return {
+ ...token,
+ balance: balance.toString(),
+ usdPrice,
+ usdValue,
+ chain: chain.apiName,
+ chainName: chain.name,
+ chainIcon: chain.icon
+ };
+ });
+ } catch (error) {
+ console.error(`Error fetching token balances for ${chain.name}:`, error);
+ // Check if this is a rate limit or invalid API key issue
+ if (axios.isAxiosError(error) && error.response) {
+ if (error.response.status === 401) {
+ console.error("Moralis API key is missing or invalid");
+ } else if (error.response.status === 429) {
+ console.error("Moralis API rate limit exceeded");
+ }
+ }
+ return [];
+ }
+};
+
+export const getNativeBalance = async (address: string, chain: MoralisChain): Promise => {
+ try {
+ const response = await instance.get(`/${address}/balance`, {
+ params: {
+ chain: chain.apiName
+ }
+ });
+
+ // Get price of native token
+ const priceResponse = await instance.get(`/erc20/${chain.currency}/price`, {
+ params: {
+ chain: chain.apiName
+ }
+ });
+
+ const balance = parseInt(response.data.balance) / 1e18;
+ const usdPrice = priceResponse.data?.usdPrice || 0;
+
+ return {
+ balance: balance.toString(),
+ decimals: 18,
+ name: chain.currency,
+ symbol: chain.currency,
+ usdPrice,
+ usdValue: balance * usdPrice,
+ chain: chain.apiName,
+ chainName: chain.name,
+ chainIcon: chain.icon
+ };
+ } catch (error) {
+ console.error(`Error fetching native balance for ${chain.name}:`, error);
+ return null;
+ }
+};
+
+export const getWalletTokens = async (address: string, chains: MoralisChain[] = SUPPORTED_CHAINS): Promise<(TokenBalance | NativeBalance)[]> => {
+ try {
+ // Process chains in parallel for better performance
+ const promises = chains.flatMap(chain => [
+ getNativeBalance(address, chain),
+ getTokenBalances(address, chain)
+ ]);
+
+ const results = await Promise.allSettled(promises);
+
+ let allTokens: (TokenBalance | NativeBalance)[] = [];
+
+ results.forEach(result => {
+ if (result.status === 'fulfilled') {
+ if (Array.isArray(result.value)) {
+ allTokens = [...allTokens, ...result.value];
+ } else if (result.value) {
+ allTokens.push(result.value);
+ }
+ }
+ });
+
+ // Sort by USD value (highest first)
+ return allTokens.sort((a, b) => (b.usdValue || 0) - (a.usdValue || 0));
+ } catch (error) {
+ console.error('Error fetching wallet tokens:', error);
+ return [];
+ }
+};
diff --git a/lib/context/AuthContext.tsx b/lib/context/AuthContext.tsx
new file mode 100644
index 0000000..27ac8bb
--- /dev/null
+++ b/lib/context/AuthContext.tsx
@@ -0,0 +1,327 @@
+'use client';
+
+import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
+import { Session, User } from '@supabase/supabase-js';
+import { supabase } from '@/src/integrations/supabase/client';
+import bcrypt from 'bcryptjs'; // Import bcryptjs
+
+type AuthContextType = {
+ user: User | null;
+ session: Session | null;
+ isLoading: boolean;
+ signUp: (email: string, password: string, meta?: any) => Promise;
+ signIn: (email: string, password: string) => Promise;
+ signOut: () => Promise;
+ signInWithGoogle: () => Promise;
+ signInWithWalletConnect: (address: string) => Promise;
+ checkSession: () => Promise;
+};
+
+const AuthContext = createContext(undefined);
+
+// Function to hash a password using bcrypt
+const hashPassword = (password: string): string => {
+ const salt = bcrypt.genSaltSync(10);
+ return bcrypt.hashSync(password, salt);
+};
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(null);
+ const [session, setSession] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ // Check active session and set user
+ const checkSession = async () => {
+ setIsLoading(true);
+ try {
+ const { data, error } = await supabase.auth.getSession();
+ if (error) throw error;
+
+ setSession(data.session);
+ setUser(data.session?.user || null);
+
+ // Update localStorage if user is logged in
+ if (data.session?.user) {
+ const currentUser = {
+ id: data.session.user.id,
+ email: data.session.user.email,
+ name: data.session.user.user_metadata?.full_name || data.session.user.email?.split('@')[0],
+ isLoggedIn: true,
+ settingsKey: `settings_${data.session.user.email}`,
+ };
+ localStorage.setItem('currentUser', JSON.stringify(currentUser));
+ }
+ } catch (error) {
+ console.error('Error checking auth session:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ checkSession();
+
+ // Listen for auth changes
+ const { data: authListener } = supabase.auth.onAuthStateChange((_event, session) => {
+ setSession(session);
+ setUser(session?.user || null);
+
+ // Update localStorage based on session state
+ if (session?.user) {
+ const currentUser = {
+ id: session.user.id,
+ email: session.user.email,
+ name: session.user.user_metadata?.full_name || session.user.email?.split('@')[0],
+ isLoggedIn: true,
+ settingsKey: `settings_${session.user.email}`,
+ };
+ localStorage.setItem('currentUser', JSON.stringify(currentUser));
+ } else {
+ localStorage.removeItem('currentUser');
+ }
+
+ setIsLoading(false);
+ });
+
+ return () => {
+ authListener.subscription.unsubscribe();
+ };
+ }, []);
+
+ // Sign up a new user
+ const signUp = async (email: string, password: string, meta?: any) => {
+ try {
+ const hashedPassword = hashPassword(password); // Hash password before sign-up
+ const { data, error } = await supabase.auth.signUp({
+ email,
+ password: hashedPassword,
+ options: {
+ data: {
+ ...meta,
+ auth_provider: 'email',
+ },
+ },
+ });
+
+ if (error) throw error;
+ return data;
+ } catch (error) {
+ console.error('Error signing up:', error);
+ throw error;
+ }
+ };
+
+ // Sign in a user
+ const signIn = async (email: string, password: string) => {
+ try {
+ const hashedPassword = hashPassword(password); // Hash the password before signing in
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email,
+ password: hashedPassword, // Use the hashed password
+ });
+
+ if (error) throw error;
+
+ // Update auth provider in profiles table
+ if (data.user) {
+ await supabase
+ .from('profiles')
+ .update({ auth_provider: 'email' })
+ .eq('id', data.user.id);
+ }
+
+ return data;
+ } catch (error) {
+ console.error('Error signing in:', error);
+ throw error;
+ }
+ };
+
+ // Sign in with Google
+ const signInWithGoogle = async () => {
+ try {
+ const { error } = await supabase.auth.signInWithOAuth({
+ provider: 'google',
+ options: {
+ redirectTo: `${window.location.origin}/`,
+ queryParams: {
+ access_type: 'offline',
+ prompt: 'consent',
+ },
+ },
+ });
+
+ if (error) throw error;
+
+ // Note: We'll update the auth_provider in profiles after the OAuth callback
+ // This happens automatically through Supabase's auth state change listener
+ } catch (error) {
+ console.error('Error signing in with Google:', error);
+ throw error;
+ }
+ };
+
+ // Sign in with Wallet Connect - Modified to use a valid email format
+ const signInWithWalletConnect = async (address: string) => {
+ try {
+ if (!address) {
+ throw new Error('Wallet address is required');
+ }
+
+ // For wallet authentication, use a valid email format
+ const normalizedAddress = address.toLowerCase();
+ const validEmail = `wallet_${normalizedAddress.replace('0x', '')}@cryptopath.com`;
+ const password = `${normalizedAddress}-secure-pass`;
+
+ console.log('Attempting to authenticate with wallet address:', address);
+ console.log('Using valid email format:', validEmail);
+
+ // Try to sign in with existing account
+ try {
+ const { data: signInData, error: signInError } = await supabase.auth.signInWithPassword({
+ email: validEmail,
+ password,
+ });
+
+ if (!signInError) {
+ console.log('Successfully signed in with wallet address');
+
+ // Update the profile with wallet address if needed
+ if (signInData.user) {
+ const displayName = `Wallet ${address.slice(0, 6)}...${address.slice(-4)}`;
+
+ await supabase
+ .from('profiles')
+ .update({
+ wallet_address: address,
+ auth_provider: 'wallet',
+ display_name: displayName,
+ })
+ .eq('id', signInData.user.id);
+
+ // Store user data for frontend usage
+ const userData = {
+ id: signInData.user.id,
+ email: validEmail,
+ name: displayName,
+ walletAddress: address,
+ };
+
+ localStorage.setItem('currentUser', JSON.stringify(userData));
+
+ const currentUser = {
+ id: signInData.user.id,
+ email: validEmail,
+ name: displayName,
+ };
+
+ window.dispatchEvent(new CustomEvent('userUpdated', { detail: currentUser }));
+ }
+
+ return signInData;
+ }
+
+ console.log('Sign in failed, creating new user', signInError);
+ } catch (signInErr) {
+ console.error('Error during wallet sign in attempt:', signInErr);
+ // Continue to signup if signin fails
+ }
+
+ // Create a new user with the wallet address
+ console.log('Creating new user with wallet');
+ const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
+ email: validEmail,
+ password,
+ options: {
+ data: {
+ wallet_address: address,
+ auth_provider: 'wallet',
+ },
+ },
+ });
+
+ if (signUpError) {
+ console.error('Error creating wallet user:', signUpError);
+ throw signUpError;
+ }
+
+ console.log('Successfully created wallet user');
+
+ // Update the profile with wallet address and display name
+ if (signUpData.user) {
+ const displayName = `Wallet ${address.slice(0, 6)}...${address.slice(-4)}`;
+
+ await supabase
+ .from('profiles')
+ .update({
+ wallet_address: address,
+ auth_provider: 'wallet',
+ display_name: displayName,
+ })
+ .eq('id', signUpData.user.id);
+
+ // Store user data for frontend usage
+ const userData = {
+ id: signUpData.user.id,
+ email: validEmail,
+ name: displayName,
+ walletAddress: address,
+ };
+
+ localStorage.setItem('currentUser', JSON.stringify(userData));
+ }
+
+ return signUpData;
+ } catch (error) {
+ console.error('Error signing in with wallet:', error);
+ throw error;
+ }
+ };
+
+ // Sign out
+ const signOut = async () => {
+ try {
+ const { error } = await supabase.auth.signOut();
+ if (error) throw error;
+
+ // Clear any local storage used for authentication
+ localStorage.removeItem('currentUser');
+ } catch (error) {
+ console.error('Error signing out:', error);
+ throw error;
+ }
+ };
+
+ // Check if user has active session
+ const checkSession = async (): Promise => {
+ try {
+ const { data } = await supabase.auth.getSession();
+ return !!data.session;
+ } catch (error) {
+ console.error('Error checking session:', error);
+ return false;
+ }
+ };
+
+ const value = {
+ user,
+ session,
+ isLoading,
+ signUp,
+ signIn,
+ signOut,
+ signInWithGoogle,
+ signInWithWalletConnect,
+ checkSession,
+ };
+
+ return {children} ;
+}
+
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/lib/neo4j.ts b/lib/neo4j.ts
index 04219d8..a88535d 100644
--- a/lib/neo4j.ts
+++ b/lib/neo4j.ts
@@ -1,4 +1,5 @@
-import neo4j, { Driver } from 'neo4j-driver'
+
+import neo4j, { Driver, Session } from 'neo4j-driver'
let driver: Driver | null = null
@@ -33,25 +34,55 @@ export async function closeDriver(): Promise {
}
}
-// Helper function to run queries
+// Helper function to run queries with error handling and more detailed logging
export async function runQuery(cypher: string, params = {}) {
- const session = getDriver().session()
+ let session: Session | null = null;
+
try {
- const result = await session.run(cypher, params)
- return result.records
+ session = getDriver().session();
+ console.log(`Executing Neo4j query: ${cypher.substring(0, 100)}...`);
+ console.log(`With parameters: ${JSON.stringify(params)}`);
+
+ const startTime = Date.now();
+ const result = await session.run(cypher, params);
+ const endTime = Date.now();
+
+ console.log(`Query executed in ${endTime - startTime}ms, returned ${result.records.length} records`);
+
+ return result.records;
+ } catch (error) {
+ console.error('Neo4j query execution failed:', error);
+
+ // Check for specific Neo4j error types
+ if (error instanceof Error) {
+ if (error.message.includes('Neo.ClientError.Schema')) {
+ throw new Error(`Database schema error: ${error.message}`);
+ } else if (error.message.includes('Neo.ClientError.Procedure')) {
+ throw new Error(`Procedure call error: ${error.message}`);
+ } else if (error.message.includes('Neo.ClientError.Security')) {
+ throw new Error('Database authentication or authorization error');
+ } else if (error.message.includes('Neo.ClientError.Transaction')) {
+ throw new Error(`Transaction error: ${error.message}`);
+ }
+ }
+
+ // Re-throw the original error if it wasn't handled specifically
+ throw error;
} finally {
- await session.close()
+ if (session) {
+ await session.close();
+ }
}
}
// Initialize driver when the app starts
export async function initializeDriver(): Promise {
try {
- const driver = getDriver()
- await driver.verifyConnectivity()
- console.log('Neo4j connection established successfully')
+ const driver = getDriver();
+ await driver.verifyConnectivity();
+ console.log('Neo4j connection established successfully');
} catch (error) {
- console.error('Failed to establish Neo4j connection:', error)
- throw error
+ console.error('Failed to establish Neo4j connection:', error);
+ throw error;
}
-}
\ No newline at end of file
+}
diff --git a/lib/types.ts b/lib/types.ts
index 82f7015..a20a886 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -1,179 +1,191 @@
// lib/types.ts
-export type Coin = {
+
+
+// Transaction related types
+export interface TransactionTableProps {
+ data?: {
id: string;
- symbol: string;
- name: string;
- image: string;
- current_price: number;
- market_cap: number;
+ from: string;
+ to: string;
+ value: string;
+ timestamp: string;
+ }[];
+}
+
+// Cryptocurrency related types
+export type Coin = {
+ id: string;
+ symbol: string;
+ name: string;
+ image: string;
+ current_price: number;
+ market_cap: number;
+ market_cap_rank: number;
+ fully_diluted_valuation: number;
+ total_volume: number;
+ high_24h: number;
+ low_24h: number;
+ price_change_24h: number;
+ price_change_percentage_24h: number;
+ market_cap_change_24h: number;
+ market_cap_change_percentage_24h: number;
+ circulating_supply: number;
+ total_supply: number;
+ max_supply: number;
+ ath: number;
+ ath_change_percentage: number;
+ ath_date: string;
+ atlDance: number;
+ atl_change_percentage: number;
+ atl_date: string;
+ roi: {
+ times: number;
+ currency: string;
+ percentage: number;
+ } | null;
+ last_updated: string;
+ sparkline_in_7d: {
+ price: number[];
+ };
+ price_change_percentage_1h_in_currency: number;
+ price_change_percentage_24h_in_currency: number;
+ price_change_percentage_7d_in_currency: number;
+};
+
+export type CoinDetail = {
+ id: string;
+ symbol: string;
+ name: string;
+ description: {
+ en: string;
+ };
+ image: {
+ thumb: string;
+ small: string;
+ large: string;
+ };
+ market_cap_rank: number;
+ links: {
+ homepage: string[];
+ blockchain_site: string[];
+ official_forum_url: string[];
+ chat_url: string[];
+ announcement_url: string[];
+ twitter_screen_name: string;
+ facebook_username: string;
+ bitcointalk_thread_identifier: number | null;
+ telegram_channel_identifier: string;
+ subreddit_url: string;
+ repos_url: {
+ github: string[];
+ bitbucket: string[];
+ };
+ };
+ market_data: {
+ current_price: {
+ usd: number;
+ };
+ market_cap: {
+ usd: number;
+ };
market_cap_rank: number;
- fully_diluted_valuation: number;
- total_volume: number;
- high_24h: number;
- low_24h: number;
+ fully_diluted_valuation: {
+ usd: number;
+ };
+ total_volume: {
+ usd: number;
+ };
+ high_24h: {
+ usd: number;
+ };
+ low_24h: {
+ usd: number;
+ };
price_change_24h: number;
price_change_percentage_24h: number;
+ price_change_percentage_7d: number;
+ price_change_percentage_14d: number;
+ price_change_percentage_30d: number;
+ price_change_percentage_60d: number;
+ price_change_percentage_200d: number;
+ price_change_percentage_1y: number;
market_cap_change_24h: number;
market_cap_change_percentage_24h: number;
+ price_change_24h_in_currency: {
+ usd: number;
+ };
+ price_change_percentage_1h_in_currency: {
+ usd: number;
+ };
+ price_change_percentage_24h_in_currency: {
+ usd: number;
+ };
+ price_change_percentage_7d_in_currency: {
+ usd: number;
+ };
+ price_change_percentage_14d_in_currency: {
+ usd: number;
+ };
+ price_change_percentage_30d_in_currency: {
+ usd: number;
+ };
+ price_change_percentage_60d_in_currency: {
+ usd: number;
+ };
+ price_change_percentage_200d_in_currency: {
+ usd: number;
+ };
+ price_change_percentage_1y_in_currency: {
+ usd: number;
+ };
+ max_supply: number;
circulating_supply: number;
total_supply: number;
- max_supply: number;
- ath: number;
- ath_change_percentage: number;
- ath_date: string;
- atlDance: number;
- atl_change_percentage: number;
- atl_date: string;
- roi: {
- times: number;
- currency: string;
- percentage: number;
- } | null;
- last_updated: string;
- sparkline_in_7d: {
+ sparkline_7d: {
price: number[];
};
- price_change_percentage_1h_in_currency: number;
- price_change_percentage_24h_in_currency: number;
- price_change_percentage_7d_in_currency: number;
- };
-
- export type CoinDetail = {
- id: string;
- symbol: string;
- name: string;
- description: {
- en: string;
+ ath: {
+ usd: number;
};
- image: {
- thumb: string;
- small: string;
- large: string;
+ ath_change_percentage: {
+ usd: number;
};
- market_cap_rank: number;
- links: {
- homepage: string[];
- blockchain_site: string[];
- official_forum_url: string[];
- chat_url: string[];
- announcement_url: string[];
- twitter_screen_name: string;
- facebook_username: string;
- bitcointalk_thread_identifier: number | null;
- telegram_channel_identifier: string;
- subreddit_url: string;
- repos_url: {
- github: string[];
- bitbucket: string[];
- };
- };
- market_data: {
- current_price: {
- usd: number;
- };
- market_cap: {
- usd: number;
- };
- market_cap_rank: number;
- fully_diluted_valuation: {
- usd: number;
- };
- total_volume: {
- usd: number;
- };
- high_24h: {
- usd: number;
- };
- low_24h: {
- usd: number;
- };
- price_change_24h: number;
- price_change_percentage_24h: number;
- price_change_percentage_7d: number;
- price_change_percentage_14d: number;
- price_change_percentage_30d: number;
- price_change_percentage_60d: number;
- price_change_percentage_200d: number;
- price_change_percentage_1y: number;
- market_cap_change_24h: number;
- market_cap_change_percentage_24h: number;
- price_change_24h_in_currency: {
- usd: number;
- };
- price_change_percentage_1h_in_currency: {
- usd: number;
- };
- price_change_percentage_24h_in_currency: {
- usd: number;
- };
- price_change_percentage_7d_in_currency: {
- usd: number;
- };
- price_change_percentage_14d_in_currency: {
- usd: number;
- };
- price_change_percentage_30d_in_currency: {
- usd: number;
- };
- price_change_percentage_60d_in_currency: {
- usd: number;
- };
- price_change_percentage_200d_in_currency: {
- usd: number;
- };
- price_change_percentage_1y_in_currency: {
- usd: number;
- };
- max_supply: number;
- circulating_supply: number;
- total_supply: number;
- sparkline_7d: {
- price: number[];
- };
- ath: {
- usd: number;
- };
- ath_change_percentage: {
- usd: number;
- };
- ath_date: {
- usd: string;
- };
- atl: {
- usd: number;
- };
- atl_change_percentage: {
- usd: number;
- };
- atl_date: {
- usd: string;
- };
+ ath_date: {
+ usd: string;
};
-};
-
-
-
- export type GlobalData = {
- active_cryptocurrencies: number;
- upcoming_icos: number;
- ongoing_icos: number;
- ended_icos: number;
- markets: number;
- total_market_cap: {
- [key: string]: number;
+ atl: {
+ usd: number;
};
- total_volume: {
- [key: string]: number;
+ atl_change_percentage: {
+ usd: number;
};
- market_cap_percentage: {
- [key: string]: number;
+ atl_date: {
+ usd: string;
};
- market_cap_change_percentage_24h_usd: number;
- updated_at: number;
};
-
- export type CoinHistory = {
- prices: [number, number][];
- market_caps: [number, number][];
- total_volumes: [number, number][];
- };
\ No newline at end of file
+};
+
+export type GlobalData = {
+ active_cryptocurrencies: number;
+ upcoming_icos: number;
+ ongoing_icos: number;
+ ended_icos: number;
+ markets: number;
+ total_market_cap: {
+ [key: string]: number;
+ };
+ total_volume: {
+ [key: string]: number;
+ };
+ market_cap_percentage: {
+ [key: string]: number;
+ };
+ market_cap_change_percentage_24h_usd: number;
+ updated_at: number;
+};
+
+export type CoinHistory = {
+ prices: [number, number][];
+ market_caps: [number, number][];
+ total_volumes: [number, number][];
+};
\ No newline at end of file
diff --git a/lib/utils/syncProfileData.ts b/lib/utils/syncProfileData.ts
new file mode 100644
index 0000000..22d97fa
--- /dev/null
+++ b/lib/utils/syncProfileData.ts
@@ -0,0 +1,197 @@
+import { supabase } from "@/src/integrations/supabase/client";
+import { toast } from "sonner";
+
+type ProfileSettings = {
+ username: string;
+ profileImage: string | null;
+ backgroundImage: string | null;
+};
+
+type Wallet = {
+ id: string;
+ address: string;
+ isDefault: boolean;
+};
+
+type UserSettings = {
+ profile: ProfileSettings;
+ wallets: Wallet[];
+};
+
+// Fetch profile and wallet data from Supabase
+export const fetchProfileFromSupabase = async (userId: string): Promise => {
+ try {
+ // Fetch profile data
+ const { data: profileData, error: profileError } = await supabase
+ .from("profiles")
+ .select("*")
+ .eq("id", userId)
+ .single();
+
+ if (profileError && profileError.code !== "PGRST116") {
+ console.error("Error fetching profile from Supabase:", profileError);
+ return null;
+ }
+
+ // Fetch wallet data
+ const { data: walletData, error: walletError } = await supabase
+ .from("user_wallets")
+ .select("*")
+ .eq("user_id", userId);
+
+ if (walletError) {
+ console.error("Error fetching wallets from Supabase:", walletError);
+ return null;
+ }
+
+ // Format data for return
+ const profile: ProfileSettings = {
+ username: profileData?.display_name || profileData?.username || "User",
+ profileImage: profileData?.profile_image || null,
+ backgroundImage: profileData?.background_image || null,
+ };
+
+ const wallets: Wallet[] = walletData
+ ? walletData.map((wallet) => ({
+ id: wallet.id,
+ address: wallet.wallet_address,
+ isDefault: wallet.is_default || false,
+ }))
+ : [];
+
+ return { profile, wallets };
+ } catch (error) {
+ console.error("Error fetching profile data from Supabase:", error);
+ return null;
+ }
+};
+
+// Sync profile and wallet data to Supabase
+export const syncProfileWithSupabase = async (userId: string): Promise => {
+ try {
+ // Get settings from localStorage
+ const currentUser = localStorage.getItem("currentUser");
+ if (!currentUser) {
+ toast.error("No user information found");
+ return;
+ }
+
+ const userData = JSON.parse(currentUser);
+ const settingsKey = userData.settingsKey;
+
+ if (!settingsKey) {
+ toast.error("No settings key found");
+ return;
+ }
+
+ const savedSettings = localStorage.getItem(settingsKey);
+ if (!savedSettings) {
+ toast.error("No saved settings found");
+ return;
+ }
+
+ // Định nghĩa kiểu cho settings
+ const settings: UserSettings = JSON.parse(savedSettings);
+
+ // Update profile in Supabase
+ if (settings.profile) {
+ const { error: profileError } = await supabase
+ .from("profiles")
+ .update({
+ display_name: settings.profile.username,
+ profile_image: settings.profile.profileImage,
+ background_image: settings.profile.backgroundImage,
+ updated_at: new Date().toISOString(),
+ })
+ .eq("id", userId);
+
+ if (profileError) {
+ console.error("Error updating profile in Supabase:", profileError);
+ toast.error("Failed to update profile in database");
+ return;
+ }
+ }
+
+ // Handle wallets - more complex because we need to compare what exists already
+ if (settings.wallets && settings.wallets.length > 0) {
+ const { data: existingWallets, error: walletFetchError } = await supabase
+ .from("user_wallets")
+ .select("*")
+ .eq("user_id", userId);
+
+ if (walletFetchError) {
+ console.error("Error fetching existing wallets:", walletFetchError);
+ toast.error("Failed to fetch existing wallets from database");
+ return;
+ }
+
+ // Process each wallet from localStorage
+ for (const wallet of settings.wallets) {
+ const existingWallet = existingWallets?.find((w) => w.wallet_address === wallet.address);
+
+ if (!existingWallet) {
+ // Add new wallet
+ const { error: insertError } = await supabase
+ .from("user_wallets")
+ .insert({
+ user_id: userId,
+ wallet_address: wallet.address,
+ is_default: wallet.isDefault,
+ });
+
+ if (insertError) {
+ console.error("Error adding wallet to Supabase:", insertError);
+ toast.error(
+ `Failed to add wallet ${wallet.address.slice(0, 6)}...${wallet.address.slice(-4)} to database`
+ );
+ }
+ } else if (existingWallet.is_default !== wallet.isDefault) {
+ // Update wallet if default status changed
+ const { error: updateError } = await supabase
+ .from("user_wallets")
+ .update({ is_default: wallet.isDefault })
+ .eq("id", existingWallet.id);
+
+ if (updateError) {
+ console.error("Error updating wallet in Supabase:", updateError);
+ toast.error(
+ `Failed to update wallet ${wallet.address.slice(0, 6)}...${wallet.address.slice(-4)} in database`
+ );
+ }
+ }
+ }
+
+ // Remove wallets that exist in Supabase but not in localStorage
+ if (existingWallets) {
+ for (const existingWallet of existingWallets) {
+ const shouldKeep = settings.wallets.some((w) => w.address === existingWallet.wallet_address);
+
+ if (!shouldKeep) {
+ // Remove wallet that's no longer in the list
+ const { error: deleteError } = await supabase
+ .from("user_wallets")
+ .delete()
+ .eq("id", existingWallet.id);
+
+ if (deleteError) {
+ console.error("Error removing wallet from Supabase:", deleteError);
+ toast.error(
+ `Failed to remove wallet ${existingWallet.wallet_address.slice(
+ 0,
+ 6
+ )}...${existingWallet.wallet_address.slice(-4)} from database`
+ );
+ }
+ }
+ }
+ }
+ }
+
+ // Update last synced time
+ localStorage.setItem("lastProfileSync", new Date().toISOString());
+ } catch (error) {
+ console.error("Error syncing profile with Supabase:", error);
+ toast.error("An error occurred while syncing profile with database");
+ throw error;
+ }
+};
\ No newline at end of file
diff --git a/next.config.js b/next.config.js
new file mode 100644
index 0000000..54a91cc
--- /dev/null
+++ b/next.config.js
@@ -0,0 +1,289 @@
+// @ts-check
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ images: {
+ // why so many? all for nft...
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "api.opensea.io",
+ pathname: "/api/v1/asset/**",
+ },
+ // Add these to your existing remotePatterns array
+ {
+ protocol: "https",
+ hostname: "openseauserdata.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "i.seadn.io",
+ pathname: "/**",
+ },
+ //
+ {
+ protocol: "https",
+ hostname: "gateway.pinata.cloud",
+ pathname: "/**",
+ },
+ {
+ protocol: "http", // For local development server
+ hostname: "localhost",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "pepunks.mypinata.cloud",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "eth-mainnet.g.alchemy.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "cdn.mint.fun",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "underground.mypinata.cloud",
+ pathname: "/ipfs/**",
+ },
+ {
+ protocol: "https",
+ hostname: "arweave.net",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "nftstorage.link",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "pepunks.mypinata.cloud",
+ pathname: "/**", // Changed from "/ipfs/**" to "/**" to match all paths
+ },
+ {
+ protocol: "https",
+ hostname: "metadata.ens.domains",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "ipfs.io",
+ pathname: "/ipfs/**",
+ },
+ {
+ protocol: "https",
+ hostname: "zerion-dna.s3.us-east-1.amazonaws.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "nft-assets.skybornegenesis.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "thesadtimescdn.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "elementals-images.azuki.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "maroon-petite-shrew-493.mypinata.cloud",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "assets.bueno.art",
+ pathname: "/**",
+ },
+ // Additional common NFT image hosts
+ {
+ protocol: "https",
+ hostname: "cloudflare-ipfs.com",
+ pathname: "/ipfs/**",
+ },
+ {
+ protocol: "https",
+ hostname: "ipfs.infura.io",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "ipfs.filebase.io",
+ pathname: "/ipfs/**",
+ },
+ {
+ protocol: "https",
+ hostname: "gateway.ipfs.io",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "dweb.link",
+ pathname: "/ipfs/**",
+ },
+ {
+ protocol: "https",
+ hostname: "ipfs.4everland.io",
+ pathname: "/ipfs/**",
+ },
+ {
+ protocol: "https",
+ hostname: "cdn.jsdelivr.net",
+ pathname: "/gh/Orlandovpjr/**",
+ },
+ {
+ protocol: "https",
+ hostname: "openseauserdata.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "storage.opensea.io",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "lh3.googleusercontent.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "i.seadn.io",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "assets.rariblecdn.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "gu-ids.s3.amazonaws.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "static.alchemyapi.io",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "s2.coinmarketcap.com",
+ pathname: "/**",
+ },
+ // Adding more blockchain image hosts
+ {
+ protocol: "https",
+ hostname: "assets.foundation.app",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "ipfs.superrare.com",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "images.mirror-media.xyz",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "assets.zora.co",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "cdn.nftport.xyz",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "metadata.mintable.app",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "img.solsea.io",
+ pathname: "/**",
+ },
+ {
+ protocol: "https",
+ hostname: "1sc60ixn9c.execute-api.us-east-1.amazonaws.com",
+ pathname: "/**",
+ },
+ ],
+ // In development mode, disable the strict checking to allow any image
+ unoptimized: process.env.NODE_ENV === 'development',
+ // unoptimized: false,
+ },
+ env: {
+ ETHERSCAN_API_KEY: process.env.ETHERSCAN_API_KEY,
+ },
+ webpack: (config, { isServer }) => {
+ if (!isServer) {
+ // Client-side polyfills
+ config.resolve.fallback = {
+ ...config.resolve.fallback,
+ crypto: require.resolve("crypto-browserify"),
+ stream: require.resolve("stream-browserify"),
+ path: require.resolve("path-browserify"),
+ buffer: require.resolve("buffer/"),
+ fs: false,
+ net: false,
+ tls: false,
+ http: false,
+ https: false,
+ zlib: false,
+ };
+ }
+
+ // Ignore native module build errors
+ config.resolve.alias = {
+ ...config.resolve.alias,
+ "./build/Release/ecdh": false,
+ };
+
+ return config;
+ },
+ // Add important settings for Vercel deployment
+ experimental: {
+ // Allow more time for API routes that make external calls
+ serverComponentsExternalPackages: [],
+ },
+ // Add extra security headers
+ async headers() {
+ return [
+ {
+ source: "/(.*)",
+ headers: [
+ {
+ key: "X-Content-Type-Options",
+ value: "nosniff",
+ },
+ {
+ key: "X-Frame-Options",
+ value: "DENY",
+ },
+ {
+ key: "X-XSS-Protection",
+ value: "1; mode=block",
+ },
+ ],
+ },
+ ];
+ },
+};
+
+module.exports = nextConfig;
\ No newline at end of file
diff --git a/next.config.js.backup b/next.config.js.backup
new file mode 100644
index 0000000..c0ee7f5
--- /dev/null
+++ b/next.config.js.backup
@@ -0,0 +1,31 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ webpack: (config, { isServer }) => {
+ if (!isServer) {
+ // Client-side polyfills
+ config.resolve.fallback = {
+ ...config.resolve.fallback,
+ crypto: require.resolve('crypto-browserify'),
+ stream: require.resolve('stream-browserify'),
+ path: require.resolve('path-browserify'),
+ buffer: require.resolve('buffer/'),
+ fs: false,
+ net: false,
+ tls: false,
+ http: false,
+ https: false,
+ zlib: false
+ }
+ }
+
+ // Ignore native module build errors
+ config.resolve.alias = {
+ ...config.resolve.alias,
+ './build/Release/ecdh': false,
+ }
+
+ return config
+ },
+}
+
+module.exports = nextConfig
\ No newline at end of file
diff --git a/next.config.ts b/next.config.ts
index d13b44a..6d0170c 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -19,6 +19,15 @@ const nextConfig: NextConfig = {
env: {
ETHERSCAN_API_KEY: process.env.ETHERSCAN_API_KEY,
},
+ webpack: (config, { isServer }) => {
+ if (!isServer) {
+ config.resolve.fallback = {
+ ...config.resolve.fallback,
+ crypto: require.resolve('crypto-browserify')
+ };
+ }
+ return config;
+ }
};
export default nextConfig;
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index e6de9c0..a9eba6d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -34,7 +34,9 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@safe-global/safe-apps-provider": "^0.18.5",
"@safe-global/safe-apps-sdk": "^8.1.0",
+ "@supabase/supabase-js": "^2.49.1",
"@tanstack/react-query": "^5.67.1",
+ "@tanstack/react-virtual": "^3.13.2",
"@web3-onboard/coinbase": "^2.4.2",
"@web3-onboard/dcent": "^2.2.10",
"@web3-onboard/frontier": "^2.1.1",
@@ -50,10 +52,16 @@
"@web3-onboard/trust": "^2.1.2",
"@web3-onboard/walletconnect": "^2.6.2",
"aos": "^2.3.4",
- "axios": "^1.7.9",
+ "axios": "^1.8.3",
+ "bcrypt": "^5.1.1",
+ "bcryptjs": "^3.0.2",
+ "buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
+ "crypto-browserify": "^3.12.1",
+ "crypto-js": "^4.2.0",
+ "dotenv": "^16.4.7",
"eccrypto": "^1.1.6",
"embla-carousel-react": "^8.5.2",
"eth-crypto": "^2.7.0",
@@ -63,7 +71,7 @@
"loading-spinner": "^1.2.1",
"lucide-react": "^0.475.0",
"neo4j-driver": "^5.28.1",
- "next": "^15.2.1",
+ "next": "^15.2.2",
"nodemailer": "^6.10.0",
"particles.js": "^2.0.0",
"pino-pretty": "^13.0.0",
@@ -80,11 +88,14 @@
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
+ "test": "file:",
"vaul": "^1.1.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/aos": "^3.0.7",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/crypto-js": "^4.2.2",
"@types/node": "^20",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19",
@@ -92,7 +103,9 @@
"@types/react-router-dom": "^5.3.3",
"eslint": "^9",
"eslint-config-next": "15.1.6",
+ "path-browserify": "^1.0.1",
"postcss": "^8",
+ "stream-browserify": "^3.0.0",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
@@ -320,13 +333,13 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz",
- "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz",
+ "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.26.9",
- "@babel/types": "^7.26.9",
+ "@babel/parser": "^7.26.10",
+ "@babel/types": "^7.26.10",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^3.0.2"
@@ -499,12 +512,12 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz",
- "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
+ "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.26.9"
+ "@babel/types": "^7.26.10"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -624,16 +637,16 @@
}
},
"node_modules/@babel/traverse": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz",
- "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz",
+ "integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
- "@babel/generator": "^7.26.9",
- "@babel/parser": "^7.26.9",
+ "@babel/generator": "^7.26.10",
+ "@babel/parser": "^7.26.10",
"@babel/template": "^7.26.9",
- "@babel/types": "^7.26.9",
+ "@babel/types": "^7.26.10",
"debug": "^4.3.1",
"globals": "^11.1.0"
},
@@ -651,9 +664,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
- "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
+ "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
@@ -2761,6 +2774,26 @@
"integrity": "sha512-YpfRhY6dBjMEvW+YApoDTSVWBqb5skOyoOcAcKbQvkuV4yCBBvJXAstOPYvFp7Vgw97AQkuie7mLdx7EZahS1Q==",
"license": "MIT"
},
+ "node_modules/@mapbox/node-pre-gyp": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
+ "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "make-dir": "^3.1.0",
+ "node-fetch": "^2.6.7",
+ "nopt": "^5.0.0",
+ "npmlog": "^5.0.1",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.11"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
"node_modules/@mobily/ts-belt": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/@mobily/ts-belt/-/ts-belt-3.13.1.tgz",
@@ -2856,9 +2889,9 @@
}
},
"node_modules/@next/env": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.1.tgz",
- "integrity": "sha512-JmY0qvnPuS2NCWOz2bbby3Pe0VzdAQ7XpEB6uLIHmtXNfAsAO0KLQLkuAoc42Bxbo3/jMC3dcn9cdf+piCcG2Q==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.2.tgz",
+ "integrity": "sha512-yWgopCfA9XDR8ZH3taB5nRKtKJ1Q5fYsTOuYkzIIoS8TJ0UAUKAGF73JnGszbjk2ufAQDj6mDdgsJAFx5CLtYQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -2872,9 +2905,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.1.tgz",
- "integrity": "sha512-aWXT+5KEREoy3K5AKtiKwioeblmOvFFjd+F3dVleLvvLiQ/mD//jOOuUcx5hzcO9ISSw4lrqtUPntTpK32uXXQ==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.2.tgz",
+ "integrity": "sha512-HNBRnz+bkZ+KfyOExpUxTMR0Ow8nkkcE6IlsdEa9W/rI7gefud19+Sn1xYKwB9pdCdxIP1lPru/ZfjfA+iT8pw==",
"cpu": [
"arm64"
],
@@ -2888,9 +2921,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.1.tgz",
- "integrity": "sha512-E/w8ervu4fcG5SkLhvn1NE/2POuDCDEy5gFbfhmnYXkyONZR68qbUlJlZwuN82o7BrBVAw+tkR8nTIjGiMW1jQ==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.2.tgz",
+ "integrity": "sha512-mJOUwp7al63tDpLpEFpKwwg5jwvtL1lhRW2fI1Aog0nYCPAhxbJsaZKdoVyPZCy8MYf/iQVNDuk/+i29iLCzIA==",
"cpu": [
"x64"
],
@@ -2904,9 +2937,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.1.tgz",
- "integrity": "sha512-gXDX5lIboebbjhiMT6kFgu4svQyjoSed6dHyjx5uZsjlvTwOAnZpn13w9XDaIMFFHw7K8CpBK7HfDKw0VZvUXQ==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.2.tgz",
+ "integrity": "sha512-5ZZ0Zwy3SgMr7MfWtRE7cQWVssfOvxYfD9O7XHM7KM4nrf5EOeqwq67ZXDgo86LVmffgsu5tPO57EeFKRnrfSQ==",
"cpu": [
"arm64"
],
@@ -2920,9 +2953,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.1.tgz",
- "integrity": "sha512-3v0pF/adKZkBWfUffmB/ROa+QcNTrnmYG4/SS+r52HPwAK479XcWoES2I+7F7lcbqc7mTeVXrIvb4h6rR/iDKg==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.2.tgz",
+ "integrity": "sha512-cgKWBuFMLlJ4TWcFHl1KOaVVUAF8vy4qEvX5KsNd0Yj5mhu989QFCq1WjuaEbv/tO1ZpsQI6h/0YR8bLwEi+nA==",
"cpu": [
"arm64"
],
@@ -2936,9 +2969,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.1.tgz",
- "integrity": "sha512-RbsVq2iB6KFJRZ2cHrU67jLVLKeuOIhnQB05ygu5fCNgg8oTewxweJE8XlLV+Ii6Y6u4EHwETdUiRNXIAfpBww==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.2.tgz",
+ "integrity": "sha512-c3kWSOSsVL8rcNBBfOq1+/j2PKs2nsMwJUV4icUxRgGBwUOfppeh7YhN5s79enBQFU+8xRgVatFkhHU1QW7yUA==",
"cpu": [
"x64"
],
@@ -2952,9 +2985,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.1.tgz",
- "integrity": "sha512-QHsMLAyAIu6/fWjHmkN/F78EFPKmhQlyX5C8pRIS2RwVA7z+t9cTb0IaYWC3EHLOTjsU7MNQW+n2xGXr11QPpg==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.2.tgz",
+ "integrity": "sha512-PXTW9PLTxdNlVYgPJ0equojcq1kNu5NtwcNjRjHAB+/sdoKZ+X8FBu70fdJFadkxFIGekQTyRvPMFF+SOJaQjw==",
"cpu": [
"x64"
],
@@ -2968,9 +3001,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.1.tgz",
- "integrity": "sha512-Gk42XZXo1cE89i3hPLa/9KZ8OuupTjkDmhLaMKFohjf9brOeZVEa3BQy1J9s9TWUqPhgAEbwv6B2+ciGfe54Vw==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.2.tgz",
+ "integrity": "sha512-nG644Es5llSGEcTaXhnGWR/aThM/hIaz0jx4MDg4gWC8GfTCp8eDBWZ77CVuv2ha/uL9Ce+nPTfYkSLG67/sHg==",
"cpu": [
"arm64"
],
@@ -2984,9 +3017,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.1.tgz",
- "integrity": "sha512-YjqXCl8QGhVlMR8uBftWk0iTmvtntr41PhG1kvzGp0sUP/5ehTM+cwx25hKE54J0CRnHYjSGjSH3gkHEaHIN9g==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.2.tgz",
+ "integrity": "sha512-52nWy65S/R6/kejz3jpvHAjZDPKIbEQu4x9jDBzmB9jJfuOy5rspjKu4u77+fI4M/WzLXrrQd57hlFGzz1ubcQ==",
"cpu": [
"x64"
],
@@ -5464,6 +5497,89 @@
"typescript": ">=5"
}
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.68.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.68.0.tgz",
+ "integrity": "sha512-odG7nb7aOmZPUXk6SwL2JchSsn36Ppx11i2yWMIc/meUO2B2HK9YwZHPK06utD9Ql9ke7JKDbwGin/8prHKxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "^2.6.14"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz",
+ "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "^2.6.14"
+ }
+ },
+ "node_modules/@supabase/node-fetch": {
+ "version": "2.6.15",
+ "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
+ "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "1.19.2",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.2.tgz",
+ "integrity": "sha512-MXRbk4wpwhWl9IN6rIY1mR8uZCCG4MZAEji942ve6nMwIqnBgBnZhZlON6zTTs6fgveMnoCILpZv1+K91jN+ow==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "^2.6.14"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz",
+ "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "^2.6.14",
+ "@types/phoenix": "^1.5.4",
+ "@types/ws": "^8.5.10",
+ "ws": "^8.18.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js/node_modules/@types/ws": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz",
+ "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/node-fetch": "^2.6.14"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.49.1",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.1.tgz",
+ "integrity": "sha512-lKaptKQB5/juEF5+jzmBeZlz69MdHZuxf+0f50NwhL+IE//m4ZnOeWlsKRjjsM0fVayZiQKqLvYdBn0RLkhGiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.68.0",
+ "@supabase/functions-js": "2.4.4",
+ "@supabase/node-fetch": "2.6.15",
+ "@supabase/postgrest-js": "1.19.2",
+ "@supabase/realtime-js": "2.11.2",
+ "@supabase/storage-js": "2.7.1"
+ }
+ },
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -5505,6 +5621,33 @@
"react": "^18 || ^19"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.2.tgz",
+ "integrity": "sha512-LceSUgABBKF6HSsHK2ZqHzQ37IKV/jlaWbHm+NyTa3/WNb/JZVcThDuTainf+PixltOOcFCYXwxbLpOX9sCx+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.2.tgz",
+ "integrity": "sha512-Qzz4EgzMbO5gKrmqUondCjiHcuu4B1ftHb0pjCut661lXZdGoHeze9f/M8iwsK1t5LGR6aNuNGU7mxkowaW6RQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@trezor/analytics": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@trezor/analytics/-/analytics-1.3.0.tgz",
@@ -5949,6 +6092,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/bn.js": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.1.6.tgz",
@@ -5973,6 +6123,13 @@
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
+ "node_modules/@types/crypto-js": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
+ "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@@ -6098,6 +6255,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/phoenix": {
+ "version": "1.6.6",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
+ "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.0.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz",
@@ -8271,6 +8434,12 @@
"ethers": ">=5.5 < 6"
}
},
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "license": "ISC"
+ },
"node_modules/abitype": {
"version": "0.9.8",
"resolved": "https://registry.npmjs.org/abitype/-/abitype-0.9.8.tgz",
@@ -8439,6 +8608,26 @@
"lodash.throttle": "^4.0.1"
}
},
+ "node_modules/aproba": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+ "license": "ISC"
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -8632,6 +8821,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/asn1.js": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
+ "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "node_modules/asn1.js/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "license": "MIT"
+ },
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
@@ -8703,9 +8909,9 @@
}
},
"node_modules/axios": {
- "version": "1.7.9",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
- "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
+ "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -8727,7 +8933,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true,
"license": "MIT"
},
"node_modules/base-x": {
@@ -8800,6 +9005,35 @@
"safe-buffer": "^5.1.2"
}
},
+ "node_modules/bcrypt": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
+ "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@mapbox/node-pre-gyp": "^1.0.11",
+ "node-addon-api": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/bcrypt/node_modules/node-addon-api": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
+ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
+ "license": "MIT"
+ },
+ "node_modules/bcryptjs": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
+ "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/bech32": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz",
@@ -8977,7 +9211,6 @@
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -9017,6 +9250,119 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/browserify-cipher": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
+ "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
+ "license": "MIT",
+ "dependencies": {
+ "browserify-aes": "^1.0.4",
+ "browserify-des": "^1.0.0",
+ "evp_bytestokey": "^1.0.0"
+ }
+ },
+ "node_modules/browserify-des": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz",
+ "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==",
+ "license": "MIT",
+ "dependencies": {
+ "cipher-base": "^1.0.1",
+ "des.js": "^1.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/browserify-rsa": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz",
+ "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^5.2.1",
+ "randombytes": "^2.1.0",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/browserify-sign": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.3.tgz",
+ "integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==",
+ "license": "ISC",
+ "dependencies": {
+ "bn.js": "^5.2.1",
+ "browserify-rsa": "^4.1.0",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "elliptic": "^6.5.5",
+ "hash-base": "~3.0",
+ "inherits": "^2.0.4",
+ "parse-asn1": "^5.1.7",
+ "readable-stream": "^2.3.8",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.12"
+ }
+ },
+ "node_modules/browserify-sign/node_modules/hash-base": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz",
+ "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/browserify-sign/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/browserify-sign/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/browserify-sign/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/bs58": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
@@ -9268,6 +9614,15 @@
"node": ">= 6"
}
},
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/cipher-base": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz",
@@ -9455,6 +9810,15 @@
"simple-swizzle": "^0.2.2"
}
},
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "license": "ISC",
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -9487,9 +9851,14 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "license": "ISC"
+ },
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
@@ -9511,6 +9880,12 @@
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"license": "MIT"
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -9523,6 +9898,22 @@
"node": ">=0.8"
}
},
+ "node_modules/create-ecdh": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
+ "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "elliptic": "^6.5.3"
+ }
+ },
+ "node_modules/create-ecdh/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "license": "MIT"
+ },
"node_modules/create-hash": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
@@ -9583,12 +9974,57 @@
"uncrypto": "^0.1.3"
}
},
+ "node_modules/crypto-browserify": {
+ "version": "3.12.1",
+ "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz",
+ "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==",
+ "license": "MIT",
+ "dependencies": {
+ "browserify-cipher": "^1.0.1",
+ "browserify-sign": "^4.2.3",
+ "create-ecdh": "^4.0.4",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "diffie-hellman": "^5.0.3",
+ "hash-base": "~3.0.4",
+ "inherits": "^2.0.4",
+ "pbkdf2": "^3.1.2",
+ "public-encrypt": "^4.0.3",
+ "randombytes": "^2.1.0",
+ "randomfill": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/crypto-browserify/node_modules/hash-base": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz",
+ "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/crypto-es": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/crypto-es/-/crypto-es-1.2.7.tgz",
"integrity": "sha512-UUqiVJ2gUuZFmbFsKmud3uuLcNP2+Opt+5ysmljycFCyhA0+T16XJmo1ev/t5kMChMqWh7IEvURNCqsg+SjZGQ==",
"license": "MIT"
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -10089,14 +10525,30 @@
"node": ">=0.4.0"
}
},
- "node_modules/destr": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz",
- "integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==",
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
- "node_modules/detect-browser": {
- "version": "5.3.0",
+ "node_modules/des.js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz",
+ "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "node_modules/destr": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz",
+ "integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==",
+ "license": "MIT"
+ },
+ "node_modules/detect-browser": {
+ "version": "5.3.0",
"resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.3.0.tgz",
"integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==",
"license": "MIT"
@@ -10106,7 +10558,6 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"license": "Apache-2.0",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -10124,6 +10575,23 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/diffie-hellman": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
+ "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "miller-rabin": "^4.0.0",
+ "randombytes": "^2.0.0"
+ }
+ },
+ "node_modules/diffie-hellman/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "license": "MIT"
+ },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@@ -10165,6 +10633,18 @@
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
+ "node_modules/dotenv": {
+ "version": "16.4.7",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
+ "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/drbg.js": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/drbg.js/-/drbg.js-1.0.1.tgz",
@@ -12593,6 +13073,42 @@
}
}
},
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "license": "ISC"
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -12648,6 +13164,74 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/gauge/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/gauge/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/gauge/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
+ "node_modules/gauge/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/gauge/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -12976,6 +13560,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "license": "ISC"
+ },
"node_modules/hash-base": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz",
@@ -13203,6 +13793,17 @@
"node": ">=12"
}
},
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -14362,6 +14963,30 @@
"localforage": "^1.7.4"
}
},
+ "node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -14425,6 +15050,25 @@
"node": ">=8.6"
}
},
+ "node_modules/miller-rabin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
+ "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "brorand": "^1.0.1"
+ },
+ "bin": {
+ "miller-rabin": "bin/miller-rabin"
+ }
+ },
+ "node_modules/miller-rabin/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "license": "MIT"
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -14479,7 +15123,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -14507,6 +15150,49 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minizlib/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/motion": {
"version": "10.16.2",
"resolved": "https://registry.npmjs.org/motion/-/motion-10.16.2.tgz",
@@ -14629,12 +15315,12 @@
"license": "Apache-2.0"
},
"node_modules/next": {
- "version": "15.2.1",
- "resolved": "https://registry.npmjs.org/next/-/next-15.2.1.tgz",
- "integrity": "sha512-zxbsdQv3OqWXybK5tMkPCBKyhIz63RstJ+NvlfkaLMc/m5MwXgz2e92k+hSKcyBpyADhMk2C31RIiaDjUZae7g==",
+ "version": "15.2.2",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.2.2.tgz",
+ "integrity": "sha512-dgp8Kcx5XZRjMw2KNwBtUzhngRaURPioxoNIVl5BOyJbhi9CUgEtKDO7fx5wh8Z8vOVX1nYZ9meawJoRrlASYA==",
"license": "MIT",
"dependencies": {
- "@next/env": "15.2.1",
+ "@next/env": "15.2.2",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15",
"busboy": "1.6.0",
@@ -14649,14 +15335,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "15.2.1",
- "@next/swc-darwin-x64": "15.2.1",
- "@next/swc-linux-arm64-gnu": "15.2.1",
- "@next/swc-linux-arm64-musl": "15.2.1",
- "@next/swc-linux-x64-gnu": "15.2.1",
- "@next/swc-linux-x64-musl": "15.2.1",
- "@next/swc-win32-arm64-msvc": "15.2.1",
- "@next/swc-win32-x64-msvc": "15.2.1",
+ "@next/swc-darwin-arm64": "15.2.2",
+ "@next/swc-darwin-x64": "15.2.2",
+ "@next/swc-linux-arm64-gnu": "15.2.2",
+ "@next/swc-linux-arm64-musl": "15.2.2",
+ "@next/swc-linux-x64-gnu": "15.2.2",
+ "@next/swc-linux-x64-musl": "15.2.2",
+ "@next/swc-win32-arm64-msvc": "15.2.2",
+ "@next/swc-win32-x64-msvc": "15.2.2",
"sharp": "^0.33.5"
},
"peerDependencies": {
@@ -14774,6 +15460,21 @@
"node": ">=6.0.0"
}
},
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -14783,6 +15484,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
"node_modules/number-to-bn": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/number-to-bn/-/number-to-bn-1.7.0.tgz",
@@ -15171,6 +15885,36 @@
"node": ">=6"
}
},
+ "node_modules/parse-asn1": {
+ "version": "5.1.7",
+ "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.7.tgz",
+ "integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==",
+ "license": "ISC",
+ "dependencies": {
+ "asn1.js": "^4.10.1",
+ "browserify-aes": "^1.2.0",
+ "evp_bytestokey": "^1.0.3",
+ "hash-base": "~3.0",
+ "pbkdf2": "^3.1.2",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/parse-asn1/node_modules/hash-base": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz",
+ "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/parse-headers": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.5.tgz",
@@ -15183,6 +15927,13 @@
"integrity": "sha512-8e0JIqkRbMMPlFBnF9f+92hX1s07jdkd3tqB8uHE9L+cwGGjIYjQM7QLgt0FQ5MZp6SFFYYDm/Y48pqK3ZvJOQ==",
"license": "MIT"
},
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -15192,6 +15943,15 @@
"node": ">=8"
}
},
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -15566,6 +16326,12 @@
"node": ">= 0.6.0"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/process-warning": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz",
@@ -15625,6 +16391,26 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
+ "node_modules/public-encrypt": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
+ "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.1.0",
+ "browserify-rsa": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "parse-asn1": "^5.0.0",
+ "randombytes": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "node_modules/public-encrypt/node_modules/bn.js": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
+ "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
+ "license": "MIT"
+ },
"node_modules/pump": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
@@ -15732,6 +16518,16 @@
"safe-buffer": "^5.1.0"
}
},
+ "node_modules/randomfill": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
+ "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
+ "license": "MIT",
+ "dependencies": {
+ "randombytes": "^2.0.5",
+ "safe-buffer": "^5.1.0"
+ }
+ },
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
@@ -16205,6 +17001,43 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rimraf/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/ripemd160": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
@@ -16555,7 +17388,6 @@
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
- "devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -17474,6 +18306,42 @@
"node": ">=6"
}
},
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
+ "license": "ISC",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/tar/node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tar/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/test": {
+ "resolved": "",
+ "link": true
+ },
"node_modules/text-encoding-utf-8": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
@@ -19545,6 +20413,56 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/wide-align/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/wide-align/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wif": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
@@ -19942,4 +20860,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/package.json b/package.json
index 494b67b..5c57f39 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,9 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@safe-global/safe-apps-provider": "^0.18.5",
"@safe-global/safe-apps-sdk": "^8.1.0",
+ "@supabase/supabase-js": "^2.49.1",
"@tanstack/react-query": "^5.67.1",
+ "@tanstack/react-virtual": "^3.13.2",
"@web3-onboard/coinbase": "^2.4.2",
"@web3-onboard/dcent": "^2.2.10",
"@web3-onboard/frontier": "^2.1.1",
@@ -51,10 +53,16 @@
"@web3-onboard/trust": "^2.1.2",
"@web3-onboard/walletconnect": "^2.6.2",
"aos": "^2.3.4",
- "axios": "^1.7.9",
+ "axios": "^1.8.3",
+ "bcrypt": "^5.1.1",
+ "bcryptjs": "^3.0.2",
+ "buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
+ "crypto-browserify": "^3.12.1",
+ "crypto-js": "^4.2.0",
+ "dotenv": "^16.4.7",
"eccrypto": "^1.1.6",
"embla-carousel-react": "^8.5.2",
"eth-crypto": "^2.7.0",
@@ -64,7 +72,7 @@
"loading-spinner": "^1.2.1",
"lucide-react": "^0.475.0",
"neo4j-driver": "^5.28.1",
- "next": "^15.2.1",
+ "next": "^15.2.2",
"nodemailer": "^6.10.0",
"particles.js": "^2.0.0",
"pino-pretty": "^13.0.0",
@@ -87,6 +95,8 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/aos": "^3.0.7",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/crypto-js": "^4.2.2",
"@types/node": "^20",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19",
@@ -94,7 +104,9 @@
"@types/react-router-dom": "^5.3.3",
"eslint": "^9",
"eslint-config-next": "15.1.6",
+ "path-browserify": "^1.0.1",
"postcss": "^8",
+ "stream-browserify": "^3.0.0",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
diff --git a/public/icons/arb.svg b/public/icons/arb.svg
new file mode 100644
index 0000000..c0381c2
--- /dev/null
+++ b/public/icons/arb.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/public/icons/avax.svg b/public/icons/avax.svg
new file mode 100644
index 0000000..b0874a3
--- /dev/null
+++ b/public/icons/avax.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/public/icons/base.svg b/public/icons/base.svg
new file mode 100644
index 0000000..ffa706b
--- /dev/null
+++ b/public/icons/base.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/public/icons/bnb.svg b/public/icons/bnb.svg
new file mode 100644
index 0000000..30b16b0
--- /dev/null
+++ b/public/icons/bnb.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/public/icons/chain-placeholder.png b/public/icons/chain-placeholder.png
new file mode 100644
index 0000000..5d644b0
--- /dev/null
+++ b/public/icons/chain-placeholder.png
@@ -0,0 +1,2 @@
+
+
diff --git a/public/icons/cro.svg b/public/icons/cro.svg
new file mode 100644
index 0000000..29d4489
--- /dev/null
+++ b/public/icons/cro.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/public/icons/eth.svg b/public/icons/eth.svg
new file mode 100644
index 0000000..92cafbb
--- /dev/null
+++ b/public/icons/eth.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/icons/ftm.svg b/public/icons/ftm.svg
new file mode 100644
index 0000000..2cf63df
--- /dev/null
+++ b/public/icons/ftm.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/public/icons/matic.svg b/public/icons/matic.svg
new file mode 100644
index 0000000..6b57aea
--- /dev/null
+++ b/public/icons/matic.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/public/icons/op.svg b/public/icons/op.svg
new file mode 100644
index 0000000..c088e11
--- /dev/null
+++ b/public/icons/op.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/public/icons/token-placeholder.png b/public/icons/token-placeholder.png
new file mode 100644
index 0000000..5d644b0
--- /dev/null
+++ b/public/icons/token-placeholder.png
@@ -0,0 +1,2 @@
+
+
diff --git a/public/icons/xdai.svg b/public/icons/xdai.svg
new file mode 100644
index 0000000..6e6dfd4
--- /dev/null
+++ b/public/icons/xdai.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/services/cryptoService.ts b/services/cryptoService.ts
new file mode 100644
index 0000000..f700d40
--- /dev/null
+++ b/services/cryptoService.ts
@@ -0,0 +1,407 @@
+ // CoinGecko API service
+const COINGECKO_API_BASE = 'https://api.coingecko.com/api/v3';
+
+const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+async function fetchWithRetry(url: string, retries = 3, delayMs = 1000): Promise {
+ for (let i = 0; i < retries; i++) {
+ try {
+ const response = await fetch(url);
+ if (response.ok) return response;
+
+ // If rate limited, wait longer
+ if (response.status === 429) {
+ await delay(delayMs * 2);
+ continue;
+ }
+
+ throw new Error(`HTTP error! status: ${response.status}`);
+ } catch (error) {
+ if (i === retries - 1) throw error;
+ await delay(delayMs);
+ }
+ }
+ throw new Error('Max retries reached');
+}
+
+export interface CryptoMarketData {
+ prices: [number, number][]; // [timestamp, price]
+ market_caps: [number, number][];
+ total_volumes: [number, number][];
+}
+
+export interface TokenData {
+ id: string;
+ symbol: string;
+ name: string;
+ current_price: number;
+ market_cap: number;
+ total_volume: number;
+ price_change_percentage_24h: number;
+}
+
+export interface CoinOption {
+ id: string;
+ symbol: string;
+ name: string;
+}
+
+export interface BlockchainMetrics {
+ avgBlockTime: number;
+ gasPrice: number;
+ activeValidators: number;
+ stakingAPR: number;
+}
+
+export interface GlobalMetrics {
+ total_market_cap: { usd: number };
+ total_volume: { usd: number };
+ market_cap_percentage: { [key: string]: number };
+ active_cryptocurrencies: number;
+ markets: number;
+}
+
+export interface CoinAnalytics {
+ id: string;
+ symbol: string;
+ name: string;
+ market_data: {
+ current_price: { usd: number };
+ price_change_percentage_24h: number;
+ market_cap: { usd: number };
+ total_volume: { usd: number };
+ circulating_supply: number;
+ total_supply: number;
+ max_supply: number | null;
+ };
+ community_data: {
+ twitter_followers: number;
+ reddit_subscribers: number;
+ telegram_channel_user_count: number | null;
+ };
+ developer_data: {
+ forks: number;
+ stars: number;
+ subscribers: number;
+ total_issues: number;
+ closed_issues: number;
+ pull_requests_merged: number;
+ pull_request_contributors: number;
+ commit_count_4_weeks: number;
+ };
+ public_interest_stats: {
+ alexa_rank: number;
+ };
+}
+
+export interface ChainAnalytics {
+ uniqueActiveWallets: number;
+ incomingTransactions: number;
+ incomingVolume: number;
+ contractBalance: number;
+ dailyChange: {
+ uaw: number;
+ transactions: number;
+ volume: number;
+ balance: number;
+ };
+}
+
+// Add cache for historical data
+const historicalDataCache = new Map();
+
+const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
+
+export const fetchHistoricalData = async (coinId: string, days: number = 30, currency: string = 'usd'): Promise => {
+ // Check cache first
+ const cached = historicalDataCache.get(coinId);
+ if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
+ return cached.data;
+ }
+
+ try {
+ const response = await fetchWithRetry(
+ `${COINGECKO_API_BASE}/coins/${coinId}/market_chart?vs_currency=${currency}&days=${days}&interval=daily`,
+ 3,
+ 2000
+ );
+ const data = await response.json();
+
+ if (!data.prices || !data.market_caps || !data.total_volumes ||
+ !Array.isArray(data.prices) || !Array.isArray(data.market_caps) || !Array.isArray(data.total_volumes) ||
+ data.prices.length === 0) {
+ throw new Error('Invalid or empty data received from API');
+ }
+
+ const validData = data.prices.every((price: any, index: number) => {
+ return Array.isArray(price) && price.length === 2 &&
+ typeof price[0] === 'number' && typeof price[1] === 'number' &&
+ Array.isArray(data.market_caps[index]) && data.market_caps[index].length === 2 &&
+ Array.isArray(data.total_volumes[index]) && data.total_volumes[index].length === 2;
+ });
+
+ if (!validData) {
+ throw new Error('Inconsistent or invalid data format');
+ }
+
+ const result = {
+ prices: data.prices,
+ market_caps: data.market_caps,
+ total_volumes: data.total_volumes
+ };
+
+ // Cache the result
+ historicalDataCache.set(coinId, {
+ data: result,
+ timestamp: Date.now()
+ });
+
+ return result;
+ } catch (error) {
+ console.error('Error fetching historical data:', error);
+
+ // Check if we have cached data even if expired
+ if (cached) {
+ return cached.data;
+ }
+
+ // Generate and cache mock data if no data available
+ const mockData = generateMockHistoricalData(coinId, days);
+ historicalDataCache.set(coinId, {
+ data: mockData,
+ timestamp: Date.now()
+ });
+ return mockData;
+ }
+};
+
+// Helper function to generate deterministic random number
+function seededRandom(seed: string, index: number): number {
+ const seedNum = seed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
+ return ((seedNum * (index + 1)) % 100) / 100;
+}
+
+// Helper function to generate realistic mock data
+function generateMockHistoricalData(coinId: string, days: number): CryptoMarketData {
+ const mockData: CryptoMarketData = {
+ prices: [],
+ market_caps: [],
+ total_volumes: []
+ };
+
+ const config = {
+ bitcoin: { price: 45000, volume: 30000000000, volatility: 0.03 },
+ ethereum: { price: 2500, volume: 15000000000, volatility: 0.04 },
+ binancecoin: { price: 300, volume: 5000000000, volatility: 0.035 },
+ ripple: { price: 0.5, volume: 2000000000, volatility: 0.045 },
+ cardano: { price: 0.4, volume: 1000000000, volatility: 0.05 },
+ default: { price: 100, volume: 1000000000, volatility: 0.04 }
+ };
+
+ const { price: basePrice, volume: baseVolume, volatility } =
+ (config as any)[coinId] || config.default;
+
+ const now = Math.floor(Date.now() / (24 * 60 * 60 * 1000)) * (24 * 60 * 60 * 1000); // Normalize to start of day
+ let currentPrice = basePrice;
+ let currentVolume = baseVolume;
+
+ for (let i = 0; i < days; i++) {
+ const timestamp = now - (days - 1 - i) * 24 * 60 * 60 * 1000;
+
+ // Use seeded random for consistent results
+ const priceChange = (seededRandom(coinId + 'price', i) - 0.5) * 2 * volatility;
+ currentPrice = currentPrice * (1 + priceChange);
+ currentPrice = Math.max(currentPrice, basePrice * 0.5);
+
+ const volumeChange = (seededRandom(coinId + 'volume', i) - 0.5) * 0.4;
+ currentVolume = baseVolume * (1 + volumeChange);
+
+ mockData.prices.push([timestamp, currentPrice]);
+ mockData.market_caps.push([timestamp, currentPrice * (baseVolume / 1000)]);
+ mockData.total_volumes.push([timestamp, currentVolume]);
+ }
+
+ return mockData;
+}
+
+export const fetchTopTokens = async (limit: number = 10, currency: string = 'usd'): Promise => {
+ try {
+ const response = await fetchWithRetry(
+ `${COINGECKO_API_BASE}/coins/markets?vs_currency=${currency}&order=market_cap_desc&per_page=${limit}&page=1&sparkline=false`
+ );
+ return await response.json();
+ } catch (error) {
+ console.error('Error fetching top tokens:', error);
+ throw error;
+ }
+};
+
+export const formatCurrency = (value: number): string => {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(value);
+};
+
+export const formatPercentage = (value: number): string => {
+ return `${value.toFixed(2)}%`;
+};
+
+const ETHERSCAN_BASE_URL = 'https://api.etherscan.io/api';
+
+export const fetchBlockchainMetrics = async (): Promise => {
+ // Since we don't have an API key, we'll use simulated data that updates
+ const baseBlock = Math.floor(Date.now() / 12000) + 19000000; // Simulates new blocks every ~12 seconds
+
+ return {
+ avgBlockTime: 12,
+ gasPrice: 25,
+ activeValidators: 889643,
+ stakingAPR: 3.7
+ };
+};
+
+export const fetchGlobalMetrics = async (): Promise => {
+ try {
+ const response = await fetch('https://api.coingecko.com/api/v3/global');
+ const data = await response.json();
+ return {
+ total_market_cap: { usd: data.data.total_market_cap.usd },
+ total_volume: { usd: data.data.total_volume.usd },
+ market_cap_percentage: data.data.market_cap_percentage,
+ active_cryptocurrencies: data.data.active_cryptocurrencies,
+ markets: data.data.markets
+ };
+ } catch (error) {
+ console.error('Error fetching global metrics:', error);
+ // Return fallback data if API fails
+ return {
+ total_market_cap: { usd: 2785000000000 },
+ total_volume: { usd: 89700000000 },
+ market_cap_percentage: {
+ btc: 51.2,
+ eth: 16.8,
+ usdt: 7.3,
+ bnb: 4.1,
+ xrp: 3.2
+ },
+ active_cryptocurrencies: 11250,
+ markets: 914
+ };
+ }
+};
+
+export const fetchCoinAnalytics = async (coinId: string): Promise => {
+ try {
+ const response = await fetchWithRetry(
+ `${COINGECKO_API_BASE}/coins/${coinId}?localization=false&tickers=false&market_data=true&community_data=true&developer_data=true&sparkline=false`
+ );
+ return await response.json();
+ } catch (error) {
+ console.error('Error fetching coin analytics:', error);
+ // Return mock data if API fails
+ return {
+ id: coinId,
+ symbol: coinId.toUpperCase(),
+ name: coinId.charAt(0).toUpperCase() + coinId.slice(1),
+ market_data: {
+ current_price: { usd: 0 },
+ price_change_percentage_24h: 0,
+ market_cap: { usd: 0 },
+ total_volume: { usd: 0 },
+ circulating_supply: 0,
+ total_supply: 0,
+ max_supply: null
+ },
+ community_data: {
+ twitter_followers: 0,
+ reddit_subscribers: 0,
+ telegram_channel_user_count: null
+ },
+ developer_data: {
+ forks: 0,
+ stars: 0,
+ subscribers: 0,
+ total_issues: 0,
+ closed_issues: 0,
+ pull_requests_merged: 0,
+ pull_request_contributors: 0,
+ commit_count_4_weeks: 0
+ },
+ public_interest_stats: {
+ alexa_rank: 0
+ }
+ };
+ }
+};
+
+export const fetchChainAnalytics = async (coinId: string): Promise => {
+ try {
+ // Since we don't have direct access to blockchain data, we'll simulate realistic data
+ // In a real implementation, this would fetch from blockchain APIs
+ const baseNumber = Date.now() % 1000000;
+ const randomFactor = () => 0.8 + Math.random() * 0.4; // Random number between 0.8 and 1.2
+
+ return {
+ uniqueActiveWallets: Math.floor(150000 * randomFactor()),
+ incomingTransactions: Math.floor(500000 * randomFactor()),
+ incomingVolume: Math.floor(1000000000 * randomFactor()), // $1B base
+ contractBalance: Math.floor(2000000000 * randomFactor()), // $2B base
+ dailyChange: {
+ uaw: -2.5 + Math.random() * 5, // -2.5% to +2.5%
+ transactions: -3 + Math.random() * 6, // -3% to +3%
+ volume: -5 + Math.random() * 10, // -5% to +5%
+ balance: -1 + Math.random() * 2, // -1% to +1%
+ }
+ };
+ } catch (error) {
+ console.error('Error fetching chain analytics:', error);
+ throw error;
+ }
+};
+
+export const fetchAvailableCoins = async (): Promise => {
+ try {
+ const response = await fetchWithRetry(
+ `${COINGECKO_API_BASE}/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=50&page=1&sparkline=false`
+ );
+ const data = await response.json();
+ return data.map((coin: any) => ({
+ id: coin.id,
+ symbol: coin.symbol.toUpperCase(),
+ name: coin.name
+ }));
+ } catch (error) {
+ console.error('Error fetching available coins:', error);
+ throw error;
+ }
+};
+
+// Add token contract addresses mapping
+export const TOKEN_CONTRACTS: { [key: string]: string } = {
+ 'ethereum': 'ETH', // Native ETH
+ 'usd-coin': '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
+ 'tether': '0xdAC17F958D2ee523a2206206994597C13D831ec7', // USDT
+ 'wrapped-bitcoin': '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', // WBTC
+ 'chainlink': '0x514910771AF9Ca656af840dff83E8264EcF986CA', // LINK
+ 'dai': '0x6B175474E89094C44Da98b954EedeAC495271d0F', // DAI
+ 'shiba-inu': '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', // SHIB
+ 'uniswap': '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', // UNI
+ 'aave': '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', // AAVE
+ 'maker': '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', // MKR
+ 'compound': '0xc00e94Cb662C3520282E6f5717214004A7f26888', // COMP
+ 'yearn-finance': '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', // YFI
+ 'sushi': '0x6B3595068778DD592e39A122f4f5a5cF09C90fE2', // SUSHI
+ 'curve-dao-token': '0xD533a949740bb3306d119CC777fa900bA034cd52', // CRV
+ 'synthetix': '0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F', // SNX
+ '1inch': '0x111111111117dC0aa78b770fA6A738034120C302', // 1INCH
+ 'loopring': '0xBBbbCA6A901c926F240b89EacB641d8Aec7AEafD', // LRC
+ 'enjincoin': '0xF629cBd94d3791C9250152BD8dfBDF380E2a3B9c', // ENJ
+ 'decentraland': '0x0F5D2fB29fb7d3CFeE444a200298f468908cC942', // MANA
+ 'the-sandbox': '0x3845badAde8e6dFF049820680d1F14bD3903a5d0' // SAND
+};
\ No newline at end of file
diff --git a/src/integrations/supabase/client.ts b/src/integrations/supabase/client.ts
new file mode 100644
index 0000000..63d4962
--- /dev/null
+++ b/src/integrations/supabase/client.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated. Do not edit it directly.
+import { createClient } from '@supabase/supabase-js';
+import type { Database } from './types';
+
+const SUPABASE_URL = "https://zqasnuenammuztqrtvjf.supabase.co";
+const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpxYXNudWVuYW1tdXp0cXJ0dmpmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDE3MDM0NjcsImV4cCI6MjA1NzI3OTQ2N30.vsuz-vRDryIWtjEZwYNpOghVLc3ui06OIOH8dBOYgb8";
+
+// Import the supabase client like this:
+// import { supabase } from "@/integrations/supabase/client";
+
+export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);
\ No newline at end of file
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
new file mode 100644
index 0000000..a959a4d
--- /dev/null
+++ b/src/integrations/supabase/types.ts
@@ -0,0 +1,243 @@
+export type Json =
+ | string
+ | number
+ | boolean
+ | null
+ | { [key: string]: Json | undefined }
+ | Json[]
+
+export type Database = {
+ public: {
+ Tables: {
+ cached_coin_details: {
+ Row: {
+ data: Json
+ id: string
+ last_updated: string
+ }
+ Insert: {
+ data: Json
+ id: string
+ last_updated?: string
+ }
+ Update: {
+ data?: Json
+ id?: string
+ last_updated?: string
+ }
+ Relationships: []
+ }
+ cached_coins: {
+ Row: {
+ data: Json
+ id: string
+ last_updated: string
+ }
+ Insert: {
+ data: Json
+ id: string
+ last_updated?: string
+ }
+ Update: {
+ data?: Json
+ id?: string
+ last_updated?: string
+ }
+ Relationships: []
+ }
+ cached_global_data: {
+ Row: {
+ data: Json
+ id: string
+ last_updated: string
+ }
+ Insert: {
+ data: Json
+ id: string
+ last_updated?: string
+ }
+ Update: {
+ data?: Json
+ id?: string
+ last_updated?: string
+ }
+ Relationships: []
+ }
+ profiles: {
+ Row: {
+ auth_provider: string | null
+ avatar_url: string | null
+ background_image: string | null
+ created_at: string
+ display_name: string | null
+ id: string
+ profile_image: string | null
+ updated_at: string
+ username: string | null
+ // wallet_address: string | null
+ wallets: { address: string; is_default: boolean }[] | null;
+ }
+ Insert: {
+ auth_provider?: string | null
+ avatar_url?: string | null
+ background_image?: string | null
+ created_at?: string
+ display_name?: string | null
+ id: string
+ profile_image?: string | null
+ updated_at?: string
+ username?: string | null
+ // wallet_address?: string | null
+ wallets?: { address: string; is_default: boolean }[] | null; // Thêm cột wallets
+ }
+ Update: {
+ auth_provider?: string | null
+ avatar_url?: string | null
+ background_image?: string | null
+ created_at?: string
+ display_name?: string | null
+ id?: string
+ profile_image?: string | null
+ updated_at?: string
+ username?: string | null
+ // wallet_address?: string | null
+ wallets?: { address: string; is_default: boolean }[] | null; // Thêm cột wallets
+ }
+ Relationships: []
+ }
+ user_wallets: {
+ Row: {
+ created_at: string | null
+ id: string
+ is_default: boolean | null
+ user_id: string
+ wallet_address: string
+ }
+ Insert: {
+ created_at?: string | null
+ id?: string
+ is_default?: boolean | null
+ user_id: string
+ wallet_address: string
+ }
+ Update: {
+ created_at?: string | null
+ id?: string
+ is_default?: boolean | null
+ user_id?: string
+ wallet_address?: string
+ }
+ Relationships: []
+ }
+ }
+ Views: {
+ [_ in never]: never
+ }
+ Functions: {
+ [_ in never]: never
+ }
+ Enums: {
+ [_ in never]: never
+ }
+ CompositeTypes: {
+ [_ in never]: never
+ }
+ }
+}
+
+type PublicSchema = Database[Extract]
+
+export type Tables<
+ PublicTableNameOrOptions extends
+ | keyof (PublicSchema["Tables"] & PublicSchema["Views"])
+ | { schema: keyof Database },
+ TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
+ ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
+ Database[PublicTableNameOrOptions["schema"]]["Views"])
+ : never = never,
+> = PublicTableNameOrOptions extends { schema: keyof Database }
+ ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
+ Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
+ Row: infer R
+ }
+ ? R
+ : never
+ : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
+ PublicSchema["Views"])
+ ? (PublicSchema["Tables"] &
+ PublicSchema["Views"])[PublicTableNameOrOptions] extends {
+ Row: infer R
+ }
+ ? R
+ : never
+ : never
+
+export type TablesInsert<
+ PublicTableNameOrOptions extends
+ | keyof PublicSchema["Tables"]
+ | { schema: keyof Database },
+ TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
+ ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
+ : never = never,
+> = PublicTableNameOrOptions extends { schema: keyof Database }
+ ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
+ Insert: infer I
+ }
+ ? I
+ : never
+ : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
+ ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
+ Insert: infer I
+ }
+ ? I
+ : never
+ : never
+
+export type TablesUpdate<
+ PublicTableNameOrOptions extends
+ | keyof PublicSchema["Tables"]
+ | { schema: keyof Database },
+ TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
+ ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
+ : never = never,
+> = PublicTableNameOrOptions extends { schema: keyof Database }
+ ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
+ Update: infer U
+ }
+ ? U
+ : never
+ : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
+ ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
+ Update: infer U
+ }
+ ? U
+ : never
+ : never
+
+export type Enums<
+ PublicEnumNameOrOptions extends
+ | keyof PublicSchema["Enums"]
+ | { schema: keyof Database },
+ EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
+ ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
+ : never = never,
+> = PublicEnumNameOrOptions extends { schema: keyof Database }
+ ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
+ : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
+ ? PublicSchema["Enums"][PublicEnumNameOrOptions]
+ : never
+
+export type CompositeTypes<
+ PublicCompositeTypeNameOrOptions extends
+ | keyof PublicSchema["CompositeTypes"]
+ | { schema: keyof Database },
+ CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
+ schema: keyof Database
+ }
+ ? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
+ : never = never,
+> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
+ ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
+ : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
+ ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
+ : never
diff --git a/supabase/config.toml b/supabase/config.toml
new file mode 100644
index 0000000..d5c8d62
--- /dev/null
+++ b/supabase/config.toml
@@ -0,0 +1 @@
+project_id = "zqasnuenammuztqrtvjf"
\ No newline at end of file
diff --git a/utils/imageUtils.ts b/utils/imageUtils.ts
new file mode 100644
index 0000000..53289b9
--- /dev/null
+++ b/utils/imageUtils.ts
@@ -0,0 +1,103 @@
+import { toast } from "sonner";
+
+// Keep track of hostnames we've already warned about to avoid spam
+const reportedHostnames = new Set();
+
+/**
+ * Extracts hostname from a URL string
+ */
+export const extractHostname = (url: string): string | null => {
+ try {
+ return new URL(url).hostname;
+ } catch (e) {
+ return null;
+ }
+};
+
+/**
+ * Reports an image hostname error through the debug event system
+ */
+export const reportImageHostnameError = (hostname: string) => {
+ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
+ // Only report each hostname once
+ if (!reportedHostnames.has(hostname)) {
+ reportedHostnames.add(hostname);
+
+ // Log helpful information to console
+ console.error(
+ `Next.js Image Error: Hostname "${hostname}" is not configured in next.config.js.\n` +
+ `To fix this, add the following to your next.config.js in the images.remotePatterns section:\n\n` +
+ `{\n protocol: "https",\n hostname: "${hostname}",\n pathname: "/**",\n},`
+ );
+
+ // Dispatch custom event for the debug badge to display
+ window.dispatchEvent(
+ new CustomEvent('nextImageHostnameError', {
+ detail: {
+ hostname,
+ timestamp: Date.now()
+ }
+ })
+ );
+
+ // Show a toast notification for immediate feedback
+ if (toast && typeof toast.error === 'function') {
+ toast.error(
+ `Image hostname "${hostname}" not configured`,
+ {
+ description: "Check console for details on how to fix this",
+ duration: 5000,
+ }
+ );
+ }
+ }
+ }
+};
+
+/**
+ * Safely handles image URLs in Next.js
+ * - Validates image URLs
+ * - Reports hostname errors during development
+ * - Returns a safe fallback when needed
+ */
+export const getSafeImageUrl = (url: string | undefined, fallbackUrl: string = "/images/token-placeholder.png"): string => {
+ if (!url) return fallbackUrl;
+
+ // Local images and data URLs are always safe
+ if (url.startsWith("/") || url.startsWith("data:")) {
+ return url;
+ }
+
+ // Return the URL as is - we'll handle errors on the Image component side
+ return url;
+};
+
+/**
+ * A wrapper for the Next.js Image onError handler
+ * Reports hostname errors and sets a fallback image
+ */
+export const handleImageError = (event: React.SyntheticEvent, fallbackSrc: string = "/images/token-placeholder.png") => {
+ const target = event.target as HTMLImageElement;
+ const src = target.src;
+ const hostname = extractHostname(src);
+
+ // Report the hostname error
+ if (hostname) {
+ reportImageHostnameError(hostname);
+ }
+
+ // Set fallback image
+ target.src = fallbackSrc;
+ // Prevent further error events for this image
+ target.onerror = null;
+};
+
+/**
+ * Enhanced Image component options for development mode
+ * to bypass hostname verification during development
+ */
+export const getDevImageProps = () => {
+ return process.env.NODE_ENV === 'development'
+ ? { unoptimized: false,}
+ : {};
+};