422 lines
16 KiB
TypeScript
422 lines
16 KiB
TypeScript
|
|
"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 (
|
|||
|
|
<Suspense>
|
|||
|
|
<HomeContent />
|
|||
|
|
</Suspense>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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<number, `0x${string}`> = {};
|
|||
|
|
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<number, { poolCap: string; poolCapacityPercent: number; maturity?: string; lockUp: string; status: 'active' | 'full' | 'ended' }>;
|
|||
|
|
const map: Record<number, { poolCap: string; poolCapacityPercent: number; maturity?: string; lockUp: string; status: 'active' | 'full' | 'ended' }> = {};
|
|||
|
|
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 (
|
|||
|
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 flex">
|
|||
|
|
<Sidebar />
|
|||
|
|
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
|||
|
|
{/* Top Bar */}
|
|||
|
|
<div className="bg-gray-100 dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3">
|
|||
|
|
<TopBar breadcrumbItems={breadcrumbItems} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Main Content */}
|
|||
|
|
<div className="px-4 md:px-8 pt-4 md:pt-8 pb-4 md:pb-8">
|
|||
|
|
{/* Page Title */}
|
|||
|
|
<PageTitle title="AssetX Fund Market" />
|
|||
|
|
|
|||
|
|
{/* Stats Cards */}
|
|||
|
|
<div className="mb-8">
|
|||
|
|
{loading ? (
|
|||
|
|
<StatsCardsSkeleton />
|
|||
|
|
) : (
|
|||
|
|
<StatsCards stats={displayStats} />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Assets Section */}
|
|||
|
|
<div className="flex flex-col gap-6 mt-8 md:mt-16">
|
|||
|
|
{/* Section Header with View Toggle */}
|
|||
|
|
<SectionHeader title="Assets">
|
|||
|
|
<ViewToggle value={viewMode} onChange={handleViewChange} />
|
|||
|
|
</SectionHeader>
|
|||
|
|
|
|||
|
|
{/* Product Cards - Grid or List View */}
|
|||
|
|
<div
|
|||
|
|
className="transition-opacity duration-200"
|
|||
|
|
style={{ opacity: isTransitioning ? 0 : 1 }}
|
|||
|
|
>
|
|||
|
|
{loading ? (
|
|||
|
|
viewMode === "grid" ? (
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|||
|
|
{[1, 2, 3].map((index) => (
|
|||
|
|
<div key={index}>
|
|||
|
|
<ProductCardSkeleton />
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex flex-col gap-4">
|
|||
|
|
{[1, 2, 3].map((index) => (
|
|||
|
|
<ProductCardListSkeleton key={index} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
) : viewMode === "grid" ? (
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|||
|
|
{products.map((product, index) => (
|
|||
|
|
<div
|
|||
|
|
key={product.id}
|
|||
|
|
className="animate-fade-in"
|
|||
|
|
style={{
|
|||
|
|
animationDelay: `${index * 0.1}s`,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<ProductCard
|
|||
|
|
productId={product.id}
|
|||
|
|
name={product.name}
|
|||
|
|
category={product.category}
|
|||
|
|
categoryColor={product.categoryColor}
|
|||
|
|
iconUrl={product.iconUrl}
|
|||
|
|
yieldAPY={product.yieldAPY}
|
|||
|
|
poolCap={vaultInfoMap[product.id]?.poolCap ?? product.poolCap}
|
|||
|
|
maturity={vaultInfoMap[product.id]?.maturity || "--"}
|
|||
|
|
risk={product.risk}
|
|||
|
|
riskLevel={product.riskLevel}
|
|||
|
|
lockUp={vaultInfoMap[product.id]?.lockUp ?? '--'}
|
|||
|
|
circulatingSupply={product.circulatingSupply}
|
|||
|
|
poolCapacityPercent={vaultInfoMap[product.id]?.poolCapacityPercent ?? product.poolCapacityPercent}
|
|||
|
|
status={vaultInfoMap[product.id]?.status}
|
|||
|
|
onInvest={() => handleInvest(product.id)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex flex-col gap-4">
|
|||
|
|
{products.map((product, index) => (
|
|||
|
|
<div
|
|||
|
|
key={product.id}
|
|||
|
|
className="animate-fade-in"
|
|||
|
|
style={{
|
|||
|
|
animationDelay: `${index * 0.08}s`,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<ProductCardList
|
|||
|
|
productId={product.id}
|
|||
|
|
name={product.name}
|
|||
|
|
category={product.category}
|
|||
|
|
categoryColor={product.categoryColor}
|
|||
|
|
iconUrl={product.iconUrl}
|
|||
|
|
poolCap={vaultInfoMap[product.id]?.poolCap ?? product.poolCap}
|
|||
|
|
lockUp={vaultInfoMap[product.id]?.lockUp ?? '--'}
|
|||
|
|
poolCapacityPercent={vaultInfoMap[product.id]?.poolCapacityPercent ?? product.poolCapacityPercent}
|
|||
|
|
status={vaultInfoMap[product.id]?.status}
|
|||
|
|
onInvest={() => handleInvest(product.id)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|