Files
assetx/webapp/app/market/page.tsx
default 2ee4553b71 init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
2026-03-27 11:26:43 +00:00

422 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 = nextRedemptionTimeUnix 秒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 paramsreplaceState不产生历史记录
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>
);
}