"use client"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useState, useMemo, useEffect, useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { useReadContracts, useAccount } from "wagmi"; import Sidebar from "@/components/layout/Sidebar"; import TopBar from "@/components/layout/TopBar"; import PageTitle from "@/components/layout/PageTitle"; import SectionHeader from "@/components/layout/SectionHeader"; import ViewToggle from "@/components/fundmarket/ViewToggle"; import StatsCards from "@/components/fundmarket/StatsCards"; import StatsCardsSkeleton from "@/components/fundmarket/StatsCardsSkeleton"; import ProductCard from "@/components/fundmarket/ProductCard"; import ProductCardSkeleton from "@/components/fundmarket/ProductCardSkeleton"; import ProductCardList from "@/components/fundmarket/ProductCardList"; import ProductCardListSkeleton from "@/components/fundmarket/ProductCardListSkeleton"; import { fetchProducts, fetchStats } from "@/lib/api/fundmarket"; import { fetchContracts } from "@/lib/api/contracts"; import { abis } from "@/lib/contracts"; import { useApp } from "@/contexts/AppContext"; const erc20BalanceOfAbi = [ { name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }], }, ] as const; function formatPoolCap(usd: number): string { if (usd >= 1_000_000) return `$${(usd / 1_000_000).toFixed(1)}M`; if (usd >= 1_000) return `$${(usd / 1_000).toFixed(1)}K`; return `$${usd.toFixed(2)}`; } export default function Home() { return ( ); } function HomeContent() { const router = useRouter(); const searchParams = useSearchParams(); const { t } = useApp(); const { address: userAddress, chainId: userChainId } = useAccount(); const initialView = searchParams.get("view") === "list" ? "list" : "grid"; const [viewMode, setViewMode] = useState<"grid" | "list">(initialView); const [isTransitioning, setIsTransitioning] = useState(false); const { data: products = [], isLoading: productsLoading } = useQuery({ queryKey: ["fundmarket-products"], queryFn: () => fetchProducts(), }); const { data: stats = [], isLoading: statsLoading } = useQuery({ queryKey: ["fundmarket-stats"], queryFn: () => fetchStats(), }); // 从合约注册表获取 YTAssetFactory 地址(复用已缓存的 contract-registry 查询) const { data: contractConfigs = [] } = useQuery({ queryKey: ["contract-registry"], queryFn: fetchContracts, staleTime: 5 * 60 * 1000, }); const factoryByChain = useMemo(() => { const map: Record = {}; for (const c of contractConfigs) { if (c.name === "YTAssetFactory" && c.address) { map[c.chain_id] = c.address as `0x${string}`; } } return map; }, [contractConfigs]); // 为每个有合约地址的产品构建 getVaultInfo 调用 const vaultContracts = useMemo(() => { return products .filter((p) => p.contractAddress && factoryByChain[p.chainId]) .map((p) => ({ address: factoryByChain[p.chainId], abi: abis.YTAssetFactory as any, functionName: "getVaultInfo" as const, args: [p.contractAddress as `0x${string}`], chainId: p.chainId, })); }, [products, factoryByChain]); const { data: vaultData, isLoading: isVaultLoading } = useReadContracts({ contracts: vaultContracts, query: { enabled: vaultContracts.length > 0 }, }); // 超时 3 秒后不再等合约数据,直接渲染 const [vaultTimedOut, setVaultTimedOut] = useState(false); useEffect(() => { if (vaultContracts.length === 0) return; setVaultTimedOut(false); const t = setTimeout(() => setVaultTimedOut(true), 3000); return () => clearTimeout(t); }, [vaultContracts.length]); const contractsReady = vaultContracts.length === 0 || vaultData !== undefined || vaultTimedOut; // 从合约实时数据提取 Pool Cap / Pool Capacity % / Maturity const formatLockUp = (nextRedemptionTime: bigint): string => { const ts = Number(nextRedemptionTime); if (!ts) return '--'; const diff = ts * 1000 - Date.now(); if (diff <= 0) return 'Matured'; const totalDays = Math.ceil(diff / (1000 * 60 * 60 * 24)); if (totalDays >= 365) return `${Math.floor(totalDays / 365)}y ${totalDays % 365}d`; if (totalDays >= 30) return `${Math.floor(totalDays / 30)}mo`; return `${totalDays}d`; }; const vaultInfoMap = useMemo(() => { if (!vaultData) return {} as Record; const map: Record = {}; const eligibleProducts = products.filter( (p) => p.contractAddress && factoryByChain[p.chainId] ); eligibleProducts.forEach((p, i) => { const entry = vaultData[i]; if (entry?.status !== "success" || !entry.result) return; // getVaultInfo: [exists, totalAssets, idleAssets, managedAssets, totalSupply, hardCap, usdcPrice, ytPrice, nextRedemptionTime] const res = entry.result as [boolean, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]; const hardCap = res[5]; const totalSupply = res[4]; const nextRedemptionTime = res[8]; if (hardCap === 0n) return; // Pool Cap = hardCap 直接读取(18 dec YT tokens) const poolCapTokens = Number(hardCap) / 1e18; const percent = (Number(totalSupply) / Number(hardCap)) * 100; // Maturity = nextRedemptionTime(Unix 秒),0 表示未配置,留 undefined 让外层 fallback API 值 let maturity: string | undefined; if (nextRedemptionTime > 0n) { const date = new Date(Number(nextRedemptionTime) * 1000); const dateStr = date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric", }); maturity = date < new Date() ? `${dateStr} (Matured)` : dateStr; } const isMatured = nextRedemptionTime > 0n && Number(nextRedemptionTime) * 1000 < Date.now(); const isFull = hardCap > 0n && totalSupply >= hardCap; const status: 'active' | 'full' | 'ended' = isMatured ? 'ended' : isFull ? 'full' : 'active'; map[p.id] = { poolCap: poolCapTokens > 0 ? formatPoolCap(poolCapTokens) : "--", poolCapacityPercent: Math.min(percent, 100), maturity, lockUp: formatLockUp(nextRedemptionTime), status, }; }); return map; }, [vaultData, products, factoryByChain]); // ERC20 balanceOf batch — one call per product token contract, requires wallet connected const balanceContracts = useMemo(() => { if (!userAddress) return []; return products .filter((p) => p.contractAddress && factoryByChain[p.chainId]) .map((p) => ({ address: p.contractAddress as `0x${string}`, abi: erc20BalanceOfAbi, functionName: "balanceOf" as const, args: [userAddress], chainId: p.chainId, })); }, [products, factoryByChain, userAddress]); const { data: balanceData } = useReadContracts({ contracts: balanceContracts, query: { enabled: balanceContracts.length > 0 }, }); // Compute "Your Total Balance" = Σ(tokenBalance[i] * ytPrice[i]) // ytPrice has 30 decimals, balance has 18 decimals → divide by 10^48 const totalBalanceUSD = useMemo(() => { if (!userAddress || !vaultData || !balanceData) return null; const eligibleProducts = products.filter( (p) => p.contractAddress && factoryByChain[p.chainId] ); let totalUsdMillionths = 0n; eligibleProducts.forEach((_, i) => { const vEntry = vaultData[i]; const bEntry = balanceData[i]; if (vEntry?.status !== "success" || !vEntry.result) return; if (bEntry?.status !== "success" || bEntry.result === undefined) return; const res = vEntry.result as [boolean, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]; const ytPriceRaw = res[7]; const balanceRaw = bEntry.result as bigint; if (!ytPriceRaw || !balanceRaw) return; // Multiply first, divide once: (balance * ytPrice * 1e6) / 10^48 totalUsdMillionths += (balanceRaw * ytPriceRaw * 1_000_000n) / (10n ** 48n); }); return Number(totalUsdMillionths) / 1_000_000; }, [userAddress, vaultData, balanceData, products, factoryByChain]); // Fetch net USDC deposited from backend (for earning calculation) const { data: netDepositedData } = useQuery({ queryKey: ["net-deposited", userAddress, userChainId], queryFn: async () => { if (!userAddress) return null; const chainId = userChainId ?? 97; const res = await fetch(`/api/fundmarket/net-deposited?address=${userAddress.toLowerCase()}&chain_id=${chainId}`); const json = await res.json(); return json.data as { netDepositedUSD: number } | null; }, enabled: !!userAddress, refetchInterval: 60_000, }); // Your Total Earning = currentValue (totalBalanceUSD) - netDepositedUSD // Only compute when netDepositedUSD > 0 (i.e. scanner has real data) const totalEarningUSD = useMemo(() => { if (totalBalanceUSD === null || !netDepositedData) return null; if (netDepositedData.netDepositedUSD <= 0) return null; return totalBalanceUSD - netDepositedData.netDepositedUSD; }, [totalBalanceUSD, netDepositedData]); // Stat label translation keys in API order const statLabelKeys = [ "productPage.totalValueLocked", "productPage.cumulativeYield", "productPage.yourTotalBalance", "productPage.yourTotalEarning", ]; // Override stats[2] (Your Total Balance) with computed on-chain value const displayStats = useMemo(() => { if (!stats.length) return stats; const result = stats.map((stat, i) => ({ ...stat, label: statLabelKeys[i] ? t(statLabelKeys[i]) : stat.label, })); if (result[2] && totalBalanceUSD !== null) { result[2] = { ...result[2], value: totalBalanceUSD > 0 ? `$${totalBalanceUSD >= 1_000_000 ? `${(totalBalanceUSD / 1_000_000).toFixed(2)}M` : totalBalanceUSD >= 1_000 ? `${(totalBalanceUSD / 1_000).toFixed(2)}K` : totalBalanceUSD.toFixed(2)}` : "$0.00", }; } if (result[3] && totalEarningUSD !== null) { const isPositive = totalEarningUSD >= 0; const absVal = Math.abs(totalEarningUSD); const formatted = absVal >= 1_000_000 ? `${(absVal / 1_000_000).toFixed(2)}M` : absVal >= 1_000 ? `${(absVal / 1_000).toFixed(2)}K` : absVal.toFixed(2); result[3] = { ...result[3], value: `${isPositive ? '' : '-'}$${formatted}`, isPositive, }; } return result; }, [stats, totalBalanceUSD, totalEarningUSD, t]); const loading = productsLoading || statsLoading || (isVaultLoading && !contractsReady); const breadcrumbItems = [ { label: "ASSETX", href: "/market" }, { label: "Fund Market" }, ]; const handleViewChange = useCallback((newMode: "grid" | "list") => { if (newMode === viewMode) return; setIsTransitioning(true); // 同步更新 URL search params(replaceState,不产生历史记录) const params = new URLSearchParams(searchParams.toString()); if (newMode === "grid") { params.delete("view"); } else { params.set("view", newMode); } const qs = params.toString(); window.history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname); setTimeout(() => { setViewMode(newMode); setIsTransitioning(false); }, 200); }, [viewMode, searchParams]); const handleInvest = (productId: number) => { router.push(`/market/product/${productId}`); }; return (
{/* Top Bar */}
{/* Main Content */}
{/* Page Title */} {/* Stats Cards */}
{loading ? ( ) : ( )}
{/* Assets Section */}
{/* Section Header with View Toggle */} {/* Product Cards - Grid or List View */}
{loading ? ( viewMode === "grid" ? (
{[1, 2, 3].map((index) => (
))}
) : (
{[1, 2, 3].map((index) => ( ))}
) ) : viewMode === "grid" ? (
{products.map((product, index) => (
handleInvest(product.id)} />
))}
) : (
{products.map((product, index) => (
handleInvest(product.id)} />
))}
)}
); }