init: 初始化 AssetX 项目仓库

包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
This commit is contained in:
2026-03-27 11:26:43 +00:00
commit 2ee4553b71
634 changed files with 988255 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useRouter } from "next/navigation";
import { useApp } from "@/contexts/AppContext";
import { useAccount } from "wagmi";
import { useCollateralBalance, useCollateralValue } from "@/hooks/useCollateral";
import { fetchProducts } from "@/lib/api/fundmarket";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
interface BorrowMarketItemData {
tokenType: string;
icon: string;
iconBg: string;
name: string;
category: string;
contractAddress: string;
}
// 渐变色映射(用于不同的 YT token
const GRADIENT_COLORS: Record<string, string> = {
'YT-A': 'linear-gradient(135deg, #FF8904 0%, #F54900 100%)',
'YT-B': 'linear-gradient(135deg, #00BBA7 0%, #007A55 100%)',
'YT-C': 'linear-gradient(135deg, #667EEA 0%, #764BA2 100%)',
};
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #6B7280 0%, #374151 100%)';
// 单个代币行组件
function BorrowMarketItem({ tokenData }: { tokenData: BorrowMarketItemData }) {
const { t } = useApp();
const router = useRouter();
const { isConnected } = useAccount();
const [mounted, setMounted] = useState(false);
// 查询该代币的抵押品余额
const ytToken = useTokenBySymbol(tokenData.tokenType);
const { formattedBalance, isLoading: isBalanceLoading } = useCollateralBalance(ytToken);
// 查询抵押品价值(用 valueRaw 避免 toFixed 截断误差)
const { valueRaw: collateralValueRaw } = useCollateralValue(ytToken);
useEffect(() => {
setMounted(true);
}, []);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-6 flex items-center gap-3">
{/* Token Info */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center overflow-hidden shadow-md"
style={{ background: tokenData.iconBg }}>
<Image src={tokenData.icon || "/assets/tokens/default.svg"} alt={tokenData.name} width={32} height={32} />
</div>
<div className="flex flex-col min-w-0">
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%] truncate">
{tokenData.name}
</span>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] truncate">
{tokenData.category}
</span>
</div>
</div>
{/* Your Balance */}
<div className="flex flex-col items-center flex-1">
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("lending.yourBalance")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%] whitespace-nowrap">
{!mounted || isBalanceLoading ? '...' : `$${collateralValueRaw.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
</span>
<span className="text-[10px] text-text-tertiary dark:text-gray-400 whitespace-nowrap">
{!mounted || isBalanceLoading ? '' : `${parseFloat(formattedBalance).toLocaleString()} ${tokenData.name}`}
</span>
</div>
{/* View Details Button */}
<Button
className={`shrink-0 rounded-xl h-10 px-4 text-body-small font-bold transition-opacity ${
!isConnected || !tokenData.contractAddress
? '!bg-[#E5E7EB] !text-[#9CA1AF] cursor-not-allowed'
: 'bg-foreground text-background hover:opacity-80'
}`}
isDisabled={!isConnected || !tokenData.contractAddress}
onPress={() => router.push(`/lending/repay?token=${tokenData.tokenType}`)}
>
{t("lending.viewDetails") || "View Details"}
</Button>
</div>
);
}
// 主组件
export default function BorrowMarket({ market }: { market: 'USDC' | 'USDT' }) {
const { t } = useApp();
const [tokens, setTokens] = useState<BorrowMarketItemData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadTokens() {
setLoading(true);
try {
const products = await fetchProducts();
const tokenList: BorrowMarketItemData[] = products.map((product) => ({
tokenType: product.tokenSymbol,
icon: product.iconUrl,
iconBg: GRADIENT_COLORS[product.name] || 'linear-gradient(135deg, #667EEA 0%, #764BA2 100%)',
name: product.name,
category: product.category || `Yield Token ${product.name.split('-')[1]}`,
contractAddress: product.contractAddress || '',
}));
setTokens(tokenList);
} catch (error) {
console.error('Failed to fetch tokens:', error);
} finally {
setLoading(false);
}
}
loadTokens();
}, []);
if (loading) {
return (
<div className="flex flex-col gap-3">
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{market} {t("lending.borrowMarket")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6 h-[120px] animate-pulse">
<div className="flex items-center gap-8">
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/4" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="flex flex-col gap-3">
{/* Title */}
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{market} {t("lending.borrowMarket")}
</h2>
{/* Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tokens.map((tokenData) => (
<BorrowMarketItem
key={tokenData.tokenType}
tokenData={tokenData}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import { useApp } from "@/contexts/AppContext";
import { useReadContract, useReadContracts } from "wagmi";
import { useAccount } from "wagmi";
import { formatUnits } from "viem";
import { abis, getContractAddress } from "@/lib/contracts";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
import { useTokenList } from "@/hooks/useTokenList";
interface LendingHeaderProps {
market: 'USDC' | 'USDT';
}
export default function LendingHeader({ market }: LendingHeaderProps) {
const { t } = useApp();
const { chainId } = useAccount();
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined;
const usdcToken = useTokenBySymbol('USDC');
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18;
// 链上总供应量
const { data: totalSupply } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getTotalSupply',
query: { enabled: !!lendingProxyAddress },
});
// 链上利用率1e18 精度)
const { data: utilization } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getUtilization',
query: { enabled: !!lendingProxyAddress },
});
// 全市场抵押品总量USD
// 用 ERC20.balanceOf(lendingProxy) 获取合约实际持有的 YT token 数量
// (用户存入的抵押品都在合约里,这是最准确的总量)
const { yieldTokens } = useTokenList();
const ytAddresses = yieldTokens.map(t => t.contractAddress).filter(Boolean) as `0x${string}`[];
const erc20BalanceAbi = [{
name: 'balanceOf',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'account', type: 'address' }],
outputs: [{ name: '', type: 'uint256' }],
}] as const;
// 批量读每个 YT token 合约里 lendingProxy 的余额
const { data: balancesData } = useReadContracts({
contracts: ytAddresses.map(addr => ({
address: addr,
abi: erc20BalanceAbi,
functionName: 'balanceOf' as const,
args: [lendingProxyAddress as `0x${string}`],
chainId,
})),
query: { enabled: !!lendingProxyAddress && ytAddresses.length > 0 },
});
// 批量读每个 YT token 的价格30 位精度)
const { data: pricesData } = useReadContracts({
contracts: ytAddresses.map(addr => ({
address: addr as `0x${string}`,
abi: abis.YTToken as any,
functionName: 'ytPrice' as const,
args: [],
chainId,
})),
query: { enabled: ytAddresses.length > 0 },
});
// 计算总抵押品 USD 价值:Σ(balance[i] / 1e18 * price[i] / 1e30)
const totalCollateralUSD = (() => {
if (!balancesData || !pricesData) return null;
let sum = 0;
ytAddresses.forEach((_, i) => {
const bal = balancesData[i];
const pri = pricesData[i];
if (bal?.status !== 'success' || pri?.status !== 'success') return;
const balance = Number(formatUnits(bal.result as bigint, 18));
const price = Number(formatUnits(pri.result as bigint, 30));
sum += balance * price;
});
return sum;
})();
const displayTotalCollateral = totalCollateralUSD !== null
? `$${totalCollateralUSD.toLocaleString('en-US', { maximumFractionDigits: 2 })}`
: '--';
// 格式化总供应量
const displaySupply = totalSupply != null
? `$${parseFloat(formatUnits(totalSupply as bigint, usdcDecimals)).toLocaleString('en-US', { maximumFractionDigits: 2 })}`
: '--';
// 格式化利用率
const displayUtilization = utilization != null
? `${(Number(formatUnits(utilization as bigint, 18)) * 100).toFixed(1)}%`
: '--';
const stats = [
{
label: t("lending.totalUsdcSupply"),
value: displaySupply,
valueColor: "text-text-primary dark:text-white",
},
{
label: t("lending.utilization"),
value: displayUtilization,
valueColor: "text-[#10b981] dark:text-green-400",
},
{
label: t("lending.totalCollateral"),
value: displayTotalCollateral,
valueColor: "text-text-primary dark:text-white",
},
];
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8 flex flex-col gap-4 md:gap-6">
{/* Title Section */}
<div className="flex flex-col gap-0">
<h1 className="text-2xl md:text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">
{market} {t("lending.title")}
</h1>
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("lending.subtitle")}
</p>
</div>
{/* Stats Cards移动端2列桌面端3列 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 w-full">
{stats.map((stat, index) => (
<div
key={index}
className="bg-bg-subtle dark:bg-gray-700 rounded-2xl border border-border-gray dark:border-gray-600 p-3 md:p-4 flex flex-col gap-2"
>
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{stat.label}
</span>
<span className={`text-xl md:text-heading-h2 font-bold leading-[130%] tracking-[-0.01em] ${stat.valueColor}`}>
{stat.value}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount } from "wagmi";
import { useSuppliedBalance } from "@/hooks/useLendingSupply";
import { useCollateralValue } from "@/hooks/useCollateral";
import { useTokenList } from "@/hooks/useTokenList";
export default function LendingPlaceholder() {
const { t } = useApp();
const router = useRouter();
const { isConnected } = useAccount();
const { bySymbol } = useTokenList();
const { formattedBalance: suppliedBalance } = useSuppliedBalance();
const { valueRaw: valueA } = useCollateralValue(bySymbol['YT-A']);
const { valueRaw: valueB } = useCollateralValue(bySymbol['YT-B']);
const { valueRaw: valueC } = useCollateralValue(bySymbol['YT-C']);
const totalPortfolio = isConnected
? parseFloat(suppliedBalance) + valueA + valueB + valueC
: 0;
const portfolioDisplay = `$${totalPortfolio.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const collateralTotal = valueA + valueB + valueC;
return (
<div className="relative rounded-2xl h-[180px] overflow-hidden">
{/* Dark Background */}
<div className="absolute inset-0 bg-[#0f172b]" />
{/* Green Glow Effect */}
<div
className="absolute w-32 h-32 rounded-full right-0 top-[-64px]"
style={{
background: "rgba(0, 188, 125, 0.2)",
filter: "blur(40px)",
}}
/>
{/* Content - Centered */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col gap-4 w-[90%]">
{/* Your Portfolio Section */}
<div className="flex flex-col gap-2">
{/* Label Row */}
<div className="flex items-center justify-between h-[21px]">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("lending.yourPortfolio")}
</span>
{collateralTotal > 0 && (
<span className="text-[10px] font-bold text-[#10b981] dark:text-green-400 leading-[150%] tracking-[0.01em]">
+${collateralTotal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {t("lending.collateral")}
</span>
)}
</div>
{/* Amount */}
<div className="flex items-center gap-2">
<span className="text-heading-h2 font-bold text-white leading-[130%] tracking-[-0.01em]">
{portfolioDisplay}
</span>
</div>
</div>
{/* Supply USDC Button */}
<Button
color="default"
variant="solid"
className="h-11 rounded-xl px-6 text-body-small font-bold bg-[#282E3F] dark:bg-white dark:text-[#282E3F] text-background"
onPress={() => router.push("/lending/supply")}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
>
{t("lending.supplyUsdc")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import Image from "next/image";
import { useState, useEffect } from "react";
import { useApp } from "@/contexts/AppContext";
import { useReadContract } from "wagmi";
import { useAccount } from "wagmi";
import { formatUnits } from "viem";
import { abis, getContractAddress } from "@/lib/contracts";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
import { useQuery } from "@tanstack/react-query";
import { fetchLendingAPYHistory } from "@/lib/api/lending";
import { useLTV } from "@/hooks/useCollateral";
export default function LendingStats() {
const { t } = useApp();
const { chainId } = useAccount();
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined;
const usdcToken = useTokenBySymbol('USDC');
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18;
// 链上总借款量
const { data: totalBorrow } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getTotalBorrow',
query: { enabled: !!lendingProxyAddress },
});
// 链上利用率1e18 精度)
const { data: utilization } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getUtilization',
query: { enabled: !!lendingProxyAddress },
});
// 链上供应利率(年化 APR1e18 精度)
const { data: supplyRate } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getSupplyRate',
query: { enabled: !!lendingProxyAddress },
});
// 格式化总借款
const displayBorrowed = totalBorrow != null
? `$${parseFloat(formatUnits(totalBorrow as bigint, usdcDecimals)).toLocaleString('en-US', { maximumFractionDigits: 2 })}`
: '--';
// 格式化利用率
const utilizationPct = utilization != null
? (Number(formatUnits(utilization as bigint, 18)) * 100).toFixed(1)
: null;
// 计算供应 APYgetSupplyRate 返回年化 APR1e18 精度)
const SECONDS_PER_YEAR = 365 * 24 * 60 * 60;
const annualApr = supplyRate != null ? Number(formatUnits(supplyRate as bigint, 18)) : 0;
const perSecondRate = annualApr / SECONDS_PER_YEAR;
const supplyApy = perSecondRate > 0
? (Math.pow(1 + perSecondRate, SECONDS_PER_YEAR) - 1) * 100
: null;
const displayApy = supplyApy != null ? `${supplyApy.toFixed(2)}%` : '--';
// APY 历史数据 → 迷你柱状图
const { data: apyHistory } = useQuery({
queryKey: ['lending-apy-history-mini', chainId],
queryFn: () => fetchLendingAPYHistory('1W', chainId),
staleTime: 5 * 60 * 1000,
});
// 从历史数据中均匀采样 6 个点,归一化为柱高百分比
const chartBars = (() => {
const BAR_COLORS = ["#f2fcf7", "#e1f8ec", "#cef3e0", "#b8ecd2", "#00ad76", "#10b981"];
const points = apyHistory?.history ?? [];
if (points.length === 0) {
return BAR_COLORS.map((color, i) => ({ height: [40, 55, 45, 65, 80, 95][i], color, apy: null as number | null, time: null as string | null }));
}
const step = Math.max(1, Math.floor(points.length / 6));
const sampled = Array.from({ length: 6 }, (_, i) => {
const idx = Math.min(i * step, points.length - 1);
return points[idx];
});
const apyValues = sampled.map(p => p.supply_apy);
const min = Math.min(...apyValues);
const max = Math.max(...apyValues);
const range = max - min || 1;
return sampled.map((p, i) => ({
height: Math.round(20 + ((p.supply_apy - min) / range) * 75),
color: BAR_COLORS[i],
apy: p.supply_apy,
time: p.time,
}));
})();
const [hoveredBar, setHoveredBar] = useState<number | null>(null);
// 用户当前 LTV
const { ltv, ltvRaw } = useLTV();
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8">
{/* 移动端2列网格 + 柱状图 */}
<div className="md:hidden flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
{/* USDC Borrowed */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">{t("lending.usdcBorrowed")}</span>
<span className="text-xl font-bold text-text-primary dark:text-white">{displayBorrowed}</span>
<div className="flex items-center gap-1">
<Image src="/components/buy/icon2.svg" alt="" width={16} height={16} />
<span className="text-[11px] font-medium text-[#10b981]">
{utilizationPct != null ? `${utilizationPct}% ${t("lending.utilization")}` : t("lending.vsLastMonth")}
</span>
</div>
</div>
{/* Avg. APY */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">{t("lending.avgApy")}</span>
<span className="text-xl font-bold text-[#10b981]">{displayApy}</span>
<span className="text-[11px] text-text-tertiary dark:text-gray-400">{t("lending.stableYield")}</span>
</div>
{/* LTV */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">{t("lending.ltv")}</span>
<span className={`text-xl font-bold ${
!mounted ? 'text-text-primary dark:text-white' :
ltvRaw === 0 ? 'text-text-tertiary dark:text-gray-400' :
ltvRaw < 50 ? 'text-[#10b981]' :
ltvRaw < 70 ? 'text-[#ff6900]' : 'text-[#ef4444]'
}`}>{!mounted ? '--' : `${ltv}%`}</span>
<span className="text-[11px] text-text-tertiary dark:text-gray-400">Loan-to-Value</span>
</div>
</div>
{/* 移动端柱状图 */}
<div className="flex items-end gap-1 h-16 relative">
{chartBars.map((bar, index) => (
<div
key={index}
className="flex-1 flex flex-col items-center justify-end gap-0.5"
style={{ height: '100%' }}
>
{bar.apy !== null && (
<span className="text-[9px] font-medium text-[#10b981] leading-none whitespace-nowrap">
{bar.apy.toFixed(2)}%
</span>
)}
<div
className="w-full rounded-t-md"
style={{ backgroundColor: bar.color, height: `${bar.height}%` }}
/>
</div>
))}
</div>
</div>
{/* 桌面端:横排 + 图表 */}
<div className="hidden md:flex items-center gap-6 h-[116px]">
<div className="flex items-center gap-12 flex-1">
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{t("lending.usdcBorrowed")}</span>
<span className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">{displayBorrowed}</span>
<div className="flex items-center gap-1">
<Image src="/components/buy/icon2.svg" alt="" width={20} height={20} />
<span className="text-[12px] font-medium text-[#10b981] dark:text-green-400 leading-[16px]">
{utilizationPct != null ? `${utilizationPct}% ${t("lending.utilization")}` : t("lending.vsLastMonth")}
</span>
</div>
</div>
<div className="w-px h-12 bg-border-gray dark:bg-gray-600" />
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{t("lending.avgApy")}</span>
<span className="text-heading-h2 font-bold text-[#10b981] dark:text-green-400 leading-[130%] tracking-[-0.01em]">{displayApy}</span>
<span className="text-[12px] font-regular text-text-tertiary dark:text-gray-400 leading-[16px]">{t("lending.stableYield")}</span>
</div>
<div className="w-px h-12 bg-border-gray dark:bg-gray-600" />
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{t("lending.ltv")}</span>
<span className={`text-heading-h2 font-bold leading-[130%] tracking-[-0.01em] ${
!mounted ? 'text-text-primary dark:text-white' :
ltvRaw === 0 ? 'text-text-tertiary dark:text-gray-400' :
ltvRaw < 50 ? 'text-[#10b981] dark:text-green-400' :
ltvRaw < 70 ? 'text-[#ff6900] dark:text-orange-400' :
'text-[#ef4444] dark:text-red-400'
}`}>{!mounted ? '--' : `${ltv}%`}</span>
<span className="text-[12px] font-regular text-text-tertiary dark:text-gray-400 leading-[16px]">Loan-to-Value</span>
</div>
</div>
{/* Mini Chart */}
<div className="flex items-end gap-1 w-48 h-16 relative">
{chartBars.map((bar, index) => (
<div
key={index}
className="flex-1 relative group"
style={{ height: '100%', display: 'flex', alignItems: 'flex-end' }}
onMouseEnter={() => setHoveredBar(index)}
onMouseLeave={() => setHoveredBar(null)}
>
{hoveredBar === index && bar.apy !== null && (
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 z-10 whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none flex flex-col items-center gap-0.5">
<span className="text-[11px] font-bold text-[#10b981]">{bar.apy.toFixed(4)}%</span>
{bar.time && <span className="text-[10px] text-gray-400">{new Date(bar.time).toLocaleDateString('en-GB', { month: 'short', day: 'numeric' })}</span>}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" />
</div>
)}
<div className="w-full rounded-t-md transition-all duration-150" style={{ backgroundColor: hoveredBar === index ? '#10b981' : bar.color, height: `${bar.height}%` }} />
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,338 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount } from "wagmi";
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
import { useLendingSupply, useSuppliedBalance } from "@/hooks/useLendingSupply";
import { useLendingWithdraw } from "@/hooks/useLendingWithdraw";
import { useBorrowBalance, useMaxBorrowable } from "@/hooks/useCollateral";
import { notifyLendingBorrow, notifyLendingRepay } from "@/lib/api/lending";
import { useAppKit } from "@reown/appkit/react";
import { toast } from "sonner";
import { getTxUrl, getContractAddress, abis } from "@/lib/contracts";
import { useTokenDecimalsFromAddress } from "@/hooks/useTokenDecimals";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
interface RepayBorrowDebtProps {
refreshTrigger?: number;
}
export default function RepayBorrowDebt({ refreshTrigger }: RepayBorrowDebtProps) {
const { t } = useApp();
const { isConnected } = useAccount();
const { open } = useAppKit();
const [mounted, setMounted] = useState(false);
const [activeTab, setActiveTab] = useState<'borrow' | 'repay'>('borrow');
const [amount, setAmount] = useState('');
useEffect(() => { setMounted(true); }, []);
// On-chain data
const { formattedBalance: suppliedBalance, refetch: refetchSupply } = useSuppliedBalance();
const { formattedBalance: borrowedBalance, refetch: refetchBorrow } = useBorrowBalance();
const { formattedAvailable, available, refetch: refetchMaxBorrowable } = useMaxBorrowable();
// Refresh when collateral changes (triggered by RepaySupplyCollateral)
useEffect(() => {
if (refreshTrigger === undefined || refreshTrigger === 0) return;
refetchBorrow();
refetchMaxBorrowable();
}, [refreshTrigger]);
// Borrow = withdraw (takes USDC from protocol, increasing debt)
const {
status: borrowStatus,
error: borrowError,
isLoading: isBorrowing,
withdrawHash: borrowHash,
executeWithdraw: executeBorrow,
reset: resetBorrow,
} = useLendingWithdraw();
// Repay = supply USDC (reduces debt)
const {
status: repayStatus,
error: repayError,
isLoading: isRepaying,
approveHash: repayApproveHash,
supplyHash: repayHash,
executeApproveAndSupply: executeRepay,
reset: resetRepay,
} = useLendingSupply();
const BORROW_TOAST_ID = 'lending-borrow-tx';
const REPAY_TOAST_ID = 'lending-repay-tx';
// Borrow 交易提交 toast
useEffect(() => {
if (borrowHash && borrowStatus === 'withdrawing') {
toast.loading(t("repay.toast.borrowSubmitted"), {
id: BORROW_TOAST_ID,
description: t("repay.toast.borrowingNow"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(borrowHash), '_blank') },
});
}
}, [borrowHash, borrowStatus]);
// Repay approve 交易提交 toast
useEffect(() => {
if (repayApproveHash && repayStatus === 'approving') {
toast.loading(t("repay.toast.approvalSubmitted"), {
id: REPAY_TOAST_ID,
description: t("repay.toast.waitingConfirmation"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(repayApproveHash), '_blank') },
});
}
}, [repayApproveHash, repayStatus]);
// Repay 交易提交 toast
useEffect(() => {
if (repayHash && repayStatus === 'supplying') {
toast.loading(t("repay.toast.repaySubmitted"), {
id: REPAY_TOAST_ID,
description: t("repay.toast.repayingNow"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(repayHash), '_blank') },
});
}
}, [repayHash, repayStatus]);
// Notify backend after borrow
useEffect(() => {
if (borrowStatus === 'success' && borrowHash && amount) {
toast.success(t("repay.toast.borrowSuccess"), {
id: BORROW_TOAST_ID,
description: t("repay.toast.borrowSuccessDesc"),
duration: 5000,
});
notifyLendingBorrow(amount, borrowHash);
setTimeout(() => refetchBorrow(), 2000);
setAmount('');
setTimeout(resetBorrow, 3000);
}
}, [borrowStatus]);
// Notify backend after repay
useEffect(() => {
if (repayStatus === 'success' && repayHash && amount) {
toast.success(t("repay.toast.repaySuccess"), {
id: REPAY_TOAST_ID,
description: t("repay.toast.repaySuccessDesc"),
duration: 5000,
});
notifyLendingRepay(amount, repayHash);
setTimeout(() => { refetchBorrow(); refetchSupply(); }, 2000);
setAmount('');
setTimeout(resetRepay, 3000);
}
}, [repayStatus]);
// 错误提示
useEffect(() => {
if (borrowError) {
if (borrowError === 'Transaction cancelled') {
toast.dismiss(BORROW_TOAST_ID);
} else {
toast.error(t("repay.toast.borrowFailed"), { id: BORROW_TOAST_ID, duration: 5000 });
}
}
}, [borrowError]);
useEffect(() => {
if (repayError) {
if (repayError === 'Transaction cancelled') {
toast.dismiss(REPAY_TOAST_ID);
} else {
toast.error(t("repay.toast.repayFailed"), { id: REPAY_TOAST_ID, duration: 5000 });
}
}
}, [repayError]);
// 从产品 API 获取 USDC token 信息,优先从合约地址读取精度
const usdcToken = useTokenBySymbol('USDC');
const { chainId } = useAccount();
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined;
const usdcInputDecimals = useTokenDecimalsFromAddress(usdcToken?.contractAddress, usdcToken?.decimals ?? 18);
// 最小借贷数量
const { data: baseBorrowMinRaw } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'baseBorrowMin',
query: { enabled: !!lendingProxyAddress },
});
const baseBorrowMin = baseBorrowMinRaw != null
? parseFloat(formatUnits(baseBorrowMinRaw as bigint, usdcInputDecimals))
: 0;
const usdcDisplayDecimals = Math.min(usdcInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const isBelowMin = activeTab === 'borrow' && isValidAmount(amount) && baseBorrowMin > 0 && parseFloat(amount) < baseBorrowMin;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > usdcDisplayDecimals) return;
const maxBalance = activeTab === 'borrow' ? parseFloat(formattedAvailable) : parseFloat(borrowedBalance);
if (parseFloat(value) > maxBalance) {
setAmount(truncateDecimals(activeTab === 'borrow' ? formattedAvailable : borrowedBalance, usdcDisplayDecimals));
return;
}
setAmount(value);
};
const handleAction = () => {
if (!amount || parseFloat(amount) <= 0) return;
if (activeTab === 'borrow') {
executeBorrow(amount);
} else {
executeRepay(amount);
}
};
const handleMax = () => {
if (activeTab === 'repay') {
setAmount(truncateDecimals(borrowedBalance, usdcDisplayDecimals));
} else if (activeTab === 'borrow') {
setAmount(truncateDecimals(formattedAvailable, usdcDisplayDecimals));
}
};
const isLoading = isBorrowing || isRepaying;
const currentStatus = activeTab === 'borrow' ? borrowStatus : repayStatus;
const getButtonLabel = () => {
if (!isConnected) return t("common.connectWallet");
if (currentStatus === 'approving') return t("repay.approvingUsdc");
if (currentStatus === 'approved') return t("repay.confirmedProcessing");
if (currentStatus === 'withdrawing') return t("repay.borrowing");
if (currentStatus === 'supplying') return t("repay.repaying");
if (currentStatus === 'success') return t("common.success");
if (currentStatus === 'error') return t("common.failed");
if (amount && !isValidAmount(amount)) return t("common.invalidAmount");
if (isBelowMin) return t("repay.minBorrow").replace('{{amount}}', baseBorrowMin.toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals }));
return activeTab === 'borrow' ? t("repay.borrow") : t("repay.repay");
};
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8 flex flex-col gap-6 flex-1 shadow-md">
{/* Title */}
<h3 className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{t("repay.borrowDebt")}
</h3>
{/* USDC Balance Info */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full overflow-hidden shadow-md flex-shrink-0">
<Image
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
alt="USDC"
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-baseline gap-1">
<span className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em] truncate">
{!mounted ? '--' : parseFloat(truncateDecimals(borrowedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400 flex-shrink-0">USDC</span>
</div>
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("repay.borrowed")}
</span>
</div>
</div>
<div className="flex flex-col items-end flex-shrink-0">
<div className="flex items-baseline gap-1">
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 whitespace-nowrap">
{!mounted ? '--' : parseFloat(truncateDecimals(formattedAvailable, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-caption-tiny font-bold text-[#10b981] dark:text-green-400 flex-shrink-0">USDC</span>
</div>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 whitespace-nowrap">
{t("repay.availableToBorrow")}
</span>
</div>
</div>
{/* Tab Switcher */}
<div className="flex rounded-xl bg-fill-secondary-click dark:bg-gray-700 p-1">
<button
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
activeTab === 'borrow'
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
: 'text-text-tertiary dark:text-gray-400'
}`}
onClick={() => { setActiveTab('borrow'); setAmount(''); resetBorrow(); }}
>
{t("repay.borrow")}
</button>
<button
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
activeTab === 'repay'
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
: 'text-text-tertiary dark:text-gray-400'
}`}
onClick={() => { setActiveTab('repay'); setAmount(''); resetRepay(); }}
>
{t("repay.repay")}
</button>
</div>
{/* Amount Input */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{t("repay.amount")} (USDC)
</span>
{activeTab === 'borrow' && mounted && available > 0 && (
<button
onClick={handleMax}
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
>
{t("common.max")}: {parseFloat(truncateDecimals(formattedAvailable, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} USDC
</button>
)}
{activeTab === 'repay' && mounted && parseFloat(borrowedBalance) > 0 && (
<button
onClick={handleMax}
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
>
{t("common.max")}: {parseFloat(truncateDecimals(borrowedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} USDC
</button>
)}
</div>
<div className="flex items-center bg-bg-subtle dark:bg-gray-700 rounded-xl px-4 h-[52px] gap-2">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
className="flex-1 bg-transparent text-body-default font-bold text-text-primary dark:text-white outline-none"
/>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400">
USDC
</span>
</div>
</div>
{/* Action Button */}
<Button
isDisabled={!mounted || (isConnected && (!isValidAmount(amount) || isBelowMin || isLoading || currentStatus === 'success'))}
color="default"
className={buttonStyles({ intent: "theme" })}
onPress={() => !isConnected ? open() : handleAction()}
>
{!mounted ? 'Loading...' : getButtonLabel()}
</Button>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
import { useYTPrice } from "@/hooks/useCollateral";
import { fetchLendingStats } from "@/lib/api/lending";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
const GRADIENT_COLORS: Record<string, string> = {
'YT-A': 'linear-gradient(135deg, #FF8904 0%, #F54900 100%)',
'YT-B': 'linear-gradient(135deg, #00BBA7 0%, #007A55 100%)',
'YT-C': 'linear-gradient(135deg, #667EEA 0%, #764BA2 100%)',
};
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #6B7280 0%, #374151 100%)';
interface RepayHeaderProps {
tokenType: string;
}
export default function RepayHeader({ tokenType }: RepayHeaderProps) {
const { t } = useApp();
const [mounted, setMounted] = useState(false);
const [availableUsd, setAvailableUsd] = useState<string | null>(null);
const ytToken = useTokenBySymbol(tokenType);
const { formattedPrice, isLoading: isPriceLoading } = useYTPrice(ytToken);
useEffect(() => {
setMounted(true);
fetchLendingStats().then((stats) => {
if (stats) {
const available = stats.totalSuppliedUsd - stats.totalBorrowedUsd;
setAvailableUsd(available > 0 ? available.toLocaleString('en-US', { maximumFractionDigits: 0 }) : '0');
}
});
}, []);
const displayPrice = !mounted || isPriceLoading ? '--' : `$${parseFloat(formattedPrice).toFixed(4)}`;
const displayAvailable = availableUsd !== null ? `$${availableUsd}` : '--';
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 px-4 md:px-6 py-4 md:py-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
{/* Left Section - Token Icons and Title */}
<div className="flex items-center gap-2">
{/* Overlapping Token Icons */}
<div className="flex items-center relative">
<div
className="w-[52px] h-[52px] rounded-full flex items-center justify-center text-white text-xs font-bold shadow-md relative z-10 overflow-hidden"
style={{ background: GRADIENT_COLORS[tokenType] ?? DEFAULT_GRADIENT }}
>
{ytToken?.iconUrl ? (
<Image src={ytToken.iconUrl} alt={tokenType} width={52} height={52} className="w-full h-full object-cover" />
) : (
tokenType.replace('YT-', '')
)}
</div>
<Image
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
alt="USDC"
width={52}
height={52}
className="relative -ml-3"
/>
</div>
{/* Title and Subtitle */}
<div className="flex flex-col gap-1 ml-2">
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">
{tokenType} / USDC
</h1>
<p className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("repay.supplyToBorrow")}
</p>
</div>
</div>
{/* Right Section - Stats */}
<div className="flex items-center justify-between md:w-[262px]">
{/* Price */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("repay.price")}
</span>
<span className="text-heading-h3 font-bold text-[#10b981] dark:text-green-400 leading-[130%] tracking-[-0.005em]">
{displayPrice}
</span>
</div>
{/* Available */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("repay.available")}
</span>
<span className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{displayAvailable}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useApp } from "@/contexts/AppContext";
import { fetchProducts, fetchProductDetail } from "@/lib/api/fundmarket";
interface RepayPoolStatsProps {
tokenType: string;
}
export default function RepayPoolStats({ tokenType }: RepayPoolStatsProps) {
const { t } = useApp();
const { data: products = [] } = useQuery({
queryKey: ['token-list'],
queryFn: () => fetchProducts(true),
staleTime: 5 * 60 * 1000,
});
const product = products.find(p => p.tokenSymbol === tokenType);
const { data: detail } = useQuery({
queryKey: ['product-detail', product?.id],
queryFn: () => fetchProductDetail(product!.id),
enabled: !!product?.id,
staleTime: 5 * 60 * 1000,
});
const tvlDisplay = detail?.tvlUsd != null
? `$${detail.tvlUsd.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
: '--';
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
{/* Left Card - Total Value Locked and Utilization */}
<div className="bg-white/50 dark:bg-gray-800/50 rounded-2xl border border-border-normal dark:border-gray-700 px-4 md:px-6 py-4 flex items-center justify-between md:h-[98px]">
{/* Total Value Locked */}
<div className="flex flex-col gap-1">
<span className="text-[12px] font-bold text-[#90a1b9] dark:text-gray-400 leading-4 uppercase">
{t("repay.totalValueLocked")}
</span>
<span className="text-[20px] font-bold text-[#0f172b] dark:text-white leading-7 tracking-[-0.45px]">
{tvlDisplay}
</span>
</div>
{/* Utilization */}
<div className="flex flex-col gap-1">
<span className="text-[12px] font-bold text-[#90a1b9] dark:text-gray-400 leading-4 uppercase text-right">
{t("repay.utilization")}
</span>
<span className="text-[20px] font-bold text-[#0f172b] dark:text-white leading-7 tracking-[-0.45px] text-right">
42.8%
</span>
</div>
</div>
{/* Right Card - Reward Multiplier */}
<div className="bg-white/50 dark:bg-gray-800/50 rounded-2xl border border-border-normal dark:border-gray-700 px-4 md:px-6 py-4 flex items-center justify-between md:h-[98px]">
{/* Reward Multiplier */}
<div className="flex flex-col gap-1">
<span className="text-[12px] font-bold text-[#90a1b9] dark:text-gray-400 leading-4 uppercase">
{t("repay.rewardMultiplier")}
</span>
<span className="text-[20px] font-bold text-[#0f172b] dark:text-white leading-7 tracking-[-0.45px]">
2.5x
</span>
</div>
{/* Overlapping Circles */}
<div className="relative w-20 h-8">
{/* Green Circle */}
<div className="absolute left-0 top-0 w-8 h-8 rounded-full bg-[#00bc7d] border-2 border-white dark:border-gray-900" />
{/* Blue Circle */}
<div className="absolute left-6 top-0 w-8 h-8 rounded-full bg-[#2b7fff] border-2 border-white dark:border-gray-900" />
{/* Gray Circle with +3 */}
<div className="absolute left-12 top-0 w-8 h-8 rounded-full bg-[#e2e8f0] dark:bg-gray-600 border-2 border-white dark:border-gray-900 flex items-center justify-center">
<span className="text-[10px] font-bold text-[#45556c] dark:text-gray-300 leading-[15px] tracking-[0.12px]">
+3
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
import { useLTV, useBorrowBalance } from "@/hooks/useCollateral";
import { useSupplyAPY } from "@/hooks/useHealthFactor";
export default function RepayStats() {
const { t } = useApp();
const [mounted, setMounted] = useState(false);
const { ltvRaw } = useLTV();
const { formattedBalance: borrowedBalance } = useBorrowBalance();
const { apr: supplyApr } = useSupplyAPY();
useEffect(() => { setMounted(true); }, []);
const LIQUIDATION_LTV = 75;
const hasPosition = mounted && parseFloat(borrowedBalance) > 0;
const healthPct = hasPosition ? Math.max(0, Math.min(100, (1 - ltvRaw / LIQUIDATION_LTV) * 100)) : 0;
const getHealthLabel = () => {
if (!hasPosition) return 'No Position';
if (ltvRaw < 50) return `Safe ${healthPct.toFixed(0)}%`;
if (ltvRaw < 65) return `Warning ${healthPct.toFixed(0)}%`;
return `At Risk ${healthPct.toFixed(0)}%`;
};
const getHealthColor = () => {
if (!hasPosition) return 'text-text-tertiary dark:text-gray-400';
if (ltvRaw < 50) return 'text-[#10b981] dark:text-green-400';
if (ltvRaw < 65) return 'text-[#ff6900] dark:text-orange-400';
return 'text-[#ef4444] dark:text-red-400';
};
const getBarGradient = () => {
if (!hasPosition) return undefined;
if (ltvRaw < 50) return 'linear-gradient(90deg, rgba(0, 213, 190, 1) 0%, rgba(0, 188, 125, 1) 100%)';
if (ltvRaw < 65) return 'linear-gradient(90deg, #ff6900 0%, #ff9900 100%)';
return 'linear-gradient(90deg, #ef4444 0%, #dc2626 100%)';
};
// Net APR = getSupplyRate() directly (already represents net yield)
const netApr = supplyApr > 0 ? supplyApr.toFixed(4) : null;
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8 flex flex-col gap-8 flex-1 shadow-md">
{/* Stats Info */}
<div className="flex flex-col gap-6">
{/* NET APR */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Image src="/components/repay/icon0.svg" alt="" width={18} height={18} className="flex-shrink-0" />
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("repay.netApr")}
</span>
</div>
<span className={`text-body-small font-bold leading-[150%] whitespace-nowrap flex-shrink-0 ${netApr !== null && parseFloat(netApr) >= 0 ? 'text-[#10b981] dark:text-green-400' : 'text-[#ef4444]'}`}>
{netApr !== null ? `${parseFloat(netApr) >= 0 ? '+' : ''}${netApr}%` : '--'}
</span>
</div>
{/* Position Health */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Image src="/components/repay/icon2.svg" alt="" width={18} height={18} className="flex-shrink-0" />
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("repay.positionHealth")}
</span>
</div>
<span className={`text-body-small font-bold leading-[150%] whitespace-nowrap flex-shrink-0 ${getHealthColor()}`}>
{!mounted ? '--' : getHealthLabel()}
</span>
</div>
</div>
{/* Progress Bar */}
<div className="w-full">
<div className="w-full h-2 bg-[#f3f4f6] dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-2 rounded-full transition-all duration-500"
style={{
width: `${hasPosition ? healthPct : 0}%`,
background: getBarGradient() || '#e5e7eb',
boxShadow: hasPosition && ltvRaw < 50 ? "0px 0px 15px 0px rgba(16, 185, 129, 0.3)" : undefined,
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount } from "wagmi";
import { useLendingCollateral, useCollateralBalance, useWithdrawCollateral, useYTWalletBalance } from "@/hooks/useLendingCollateral";
import { useYTPrice } from "@/hooks/useCollateral";
import { notifySupplyCollateral, notifyWithdrawCollateral } from "@/lib/api/lending";
import { useAppKit } from "@reown/appkit/react";
import { toast } from "sonner";
import { getTxUrl } from "@/lib/contracts";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
const GRADIENT_COLORS: Record<string, string> = {
'YT-A': 'linear-gradient(135deg, #FF8904 0%, #F54900 100%)',
'YT-B': 'linear-gradient(135deg, #00BBA7 0%, #007A55 100%)',
'YT-C': 'linear-gradient(135deg, #667EEA 0%, #764BA2 100%)',
};
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #6B7280 0%, #374151 100%)';
interface RepaySupplyCollateralProps {
tokenType: string;
onCollateralChanged?: () => void;
}
export default function RepaySupplyCollateral({ tokenType, onCollateralChanged }: RepaySupplyCollateralProps) {
const { t } = useApp();
const { isConnected } = useAccount();
const { open } = useAppKit();
const [mounted, setMounted] = useState(false);
const [activeTab, setActiveTab] = useState<'deposit' | 'withdraw'>('deposit');
const [amount, setAmount] = useState('');
useEffect(() => { setMounted(true); }, []);
// On-chain data
const ytToken = useTokenBySymbol(tokenType);
const { formattedBalance, refetch: refetchBalance } = useCollateralBalance(ytToken);
const { formattedBalance: walletBalance, refetch: refetchWalletBalance } = useYTWalletBalance(ytToken);
const { formattedPrice } = useYTPrice(ytToken);
const collateralUsd = (parseFloat(formattedBalance) * parseFloat(formattedPrice)).toFixed(2);
// Deposit hook
const {
status: depositStatus,
error: depositError,
isLoading: isDepositing,
approveHash: depositApproveHash,
supplyHash,
executeApproveAndSupply,
reset: resetDeposit,
} = useLendingCollateral();
// Withdraw hook
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawing,
withdrawHash,
executeWithdrawCollateral,
reset: resetWithdraw,
} = useWithdrawCollateral();
const DEPOSIT_TOAST_ID = 'collateral-deposit-tx';
const WITHDRAW_TOAST_ID = 'collateral-withdraw-tx';
// Deposit approve 交易提交 toast
useEffect(() => {
if (depositApproveHash && depositStatus === 'approving') {
toast.loading(t("repay.toast.approvalSubmitted"), {
id: DEPOSIT_TOAST_ID,
description: t("repay.toast.waitingConfirmation"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(depositApproveHash), '_blank') },
});
}
}, [depositApproveHash, depositStatus]);
// Deposit 交易提交 toast
useEffect(() => {
if (supplyHash && depositStatus === 'supplying') {
toast.loading(t("repay.toast.depositSubmitted"), {
id: DEPOSIT_TOAST_ID,
description: t("repay.toast.depositingNow"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(supplyHash), '_blank') },
});
}
}, [supplyHash, depositStatus]);
// Withdraw 交易提交 toast
useEffect(() => {
if (withdrawHash && withdrawStatus === 'withdrawing') {
toast.loading(t("repay.toast.withdrawSubmitted"), {
id: WITHDRAW_TOAST_ID,
description: t("repay.toast.withdrawingNow"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(withdrawHash), '_blank') },
});
}
}, [withdrawHash, withdrawStatus]);
// Notify backend after successful tx
useEffect(() => {
if (depositStatus === 'success' && supplyHash && amount) {
toast.success(t("repay.toast.depositSuccess"), {
id: DEPOSIT_TOAST_ID,
description: t("repay.toast.depositSuccessDesc"),
duration: 5000,
});
notifySupplyCollateral(tokenType, amount, supplyHash);
refetchBalance();
refetchWalletBalance();
onCollateralChanged?.();
setAmount('');
setTimeout(resetDeposit, 3000);
}
}, [depositStatus]);
useEffect(() => {
if (withdrawStatus === 'success' && withdrawHash && amount) {
toast.success(t("repay.toast.withdrawSuccess"), {
id: WITHDRAW_TOAST_ID,
description: t("repay.toast.withdrawSuccessDesc"),
duration: 5000,
});
notifyWithdrawCollateral(tokenType, amount, withdrawHash);
refetchBalance();
setAmount('');
setTimeout(resetWithdraw, 3000);
}
}, [withdrawStatus]);
// 错误提示
useEffect(() => {
if (depositError) {
if (depositError === 'Transaction cancelled') {
toast.dismiss(DEPOSIT_TOAST_ID);
} else {
toast.error(t("repay.toast.depositFailed"), { id: DEPOSIT_TOAST_ID, duration: 5000 });
}
}
}, [depositError]);
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.dismiss(WITHDRAW_TOAST_ID);
} else {
toast.error(t("repay.toast.withdrawFailed"), { id: WITHDRAW_TOAST_ID, duration: 5000 });
}
}
}, [withdrawError]);
const inputDecimals = ytToken?.onChainDecimals ?? ytToken?.decimals ?? 18;
const displayDecimals = Math.min(inputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > displayDecimals) return;
const maxBalance = activeTab === 'deposit' ? parseFloat(walletBalance) : parseFloat(formattedBalance);
if (parseFloat(value) > maxBalance) {
setAmount(truncateDecimals(activeTab === 'deposit' ? walletBalance : formattedBalance, displayDecimals));
return;
}
setAmount(value);
};
const handleAction = () => {
if (!amount || parseFloat(amount) <= 0 || !ytToken) return;
if (activeTab === 'deposit') {
executeApproveAndSupply(ytToken, amount);
} else {
executeWithdrawCollateral(ytToken, amount);
}
};
const handleMax = () => {
if (activeTab === 'deposit') {
setAmount(truncateDecimals(walletBalance, displayDecimals));
} else {
setAmount(truncateDecimals(formattedBalance, displayDecimals));
}
};
const isLoading = isDepositing || isWithdrawing;
const currentStatus = activeTab === 'deposit' ? depositStatus : withdrawStatus;
const getButtonLabel = () => {
if (!isConnected) return t("common.connectWallet");
if (currentStatus === 'approving') return t("common.approving");
if (currentStatus === 'approved') return t("repay.confirmedDepositing");
if (currentStatus === 'supplying') return t("repay.depositing");
if (currentStatus === 'withdrawing') return t("repay.withdrawing");
if (currentStatus === 'success') return t("common.success");
if (currentStatus === 'error') return t("common.failed");
if (amount && !isValidAmount(amount)) return t("common.invalidAmount");
return activeTab === 'deposit' ? t("repay.deposit") : t("repay.withdraw");
};
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8 flex flex-col gap-6 flex-1 shadow-md">
{/* Title */}
<h3 className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{t("repay.supplyCollateral")}
</h3>
{/* Token Info and Value */}
<div className="flex items-center gap-3 min-w-0">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold shadow-md overflow-hidden flex-shrink-0"
style={{ background: GRADIENT_COLORS[tokenType] ?? DEFAULT_GRADIENT }}
>
{ytToken?.iconUrl ? (
<Image src={ytToken.iconUrl} alt={tokenType} width={40} height={40} className="w-full h-full object-cover" />
) : (
tokenType.replace('YT-', '')
)}
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-baseline gap-1">
<span className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em] truncate">
{!mounted ? '--' : parseFloat(formattedBalance).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })}
</span>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400 flex-shrink-0">{tokenType}</span>
</div>
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{!mounted ? '--' : `$${parseFloat(collateralUsd).toLocaleString('en-US', { maximumFractionDigits: 2 })}`}
</span>
</div>
</div>
{/* Tab Switcher */}
<div className="flex rounded-xl bg-fill-secondary-click dark:bg-gray-700 p-1">
<button
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
activeTab === 'deposit'
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
: 'text-text-tertiary dark:text-gray-400'
}`}
onClick={() => { setActiveTab('deposit'); setAmount(''); resetDeposit(); }}
>
{t("repay.deposit")}
</button>
<button
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
activeTab === 'withdraw'
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
: 'text-text-tertiary dark:text-gray-400'
}`}
onClick={() => { setActiveTab('withdraw'); setAmount(''); resetWithdraw(); }}
>
{t("repay.withdraw")}
</button>
</div>
{/* Amount Input */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{t("repay.amount")}
</span>
{activeTab === 'deposit' && mounted && (
<button
onClick={handleMax}
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
>
{t("common.max")}: {parseFloat(walletBalance).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })} {tokenType}
</button>
)}
{activeTab === 'withdraw' && mounted && (
<button
onClick={handleMax}
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
>
{t("common.max")}: {parseFloat(formattedBalance).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })} {tokenType}
</button>
)}
</div>
<div className="flex items-center bg-bg-subtle dark:bg-gray-700 rounded-xl px-4 h-[52px] gap-2">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
className="flex-1 bg-transparent text-body-default font-bold text-text-primary dark:text-white outline-none"
/>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400">
{tokenType}
</span>
</div>
</div>
{/* Action Button */}
<Button
isDisabled={!mounted || (isConnected && (!isValidAmount(amount) || isLoading || currentStatus === 'success'))}
color="default"
className={buttonStyles({ intent: "theme" })}
onPress={() => !isConnected ? open() : handleAction()}
>
{!mounted ? 'Loading...' : getButtonLabel()}
</Button>
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { useQuery } from "@tanstack/react-query";
import { useChainId } from "wagmi";
import * as echarts from "echarts";
import { fetchLendingAPYHistory } from "@/lib/api/lending";
type Period = "1W" | "1M" | "1Y";
export default function SupplyContent() {
const { t } = useApp();
const chainId = useChainId();
const [selectedPeriod, setSelectedPeriod] = useState<Period>("1W");
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { data: apyData, isLoading } = useQuery({
queryKey: ["lending-apy-history", selectedPeriod, chainId],
queryFn: () => fetchLendingAPYHistory(selectedPeriod, chainId),
staleTime: 5 * 60 * 1000,
});
const chartData = apyData?.history.map((p) => p.supply_apy) ?? [];
const xLabels = apyData?.history.map((p) => {
const d = new Date(p.time);
if (selectedPeriod === "1W") return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:00`;
if (selectedPeriod === "1M") return `${d.getMonth() + 1}/${d.getDate()}`;
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
}) ?? [];
const currentAPY = apyData?.current_supply_apy ?? 0;
const apyChange = apyData?.apy_change ?? 0;
// 初始化 & 销毁(只在 mount/unmount 执行)
useEffect(() => {
if (!chartRef.current) return;
chartInstance.current = echarts.init(chartRef.current);
const observer = new ResizeObserver(() => chartInstance.current?.resize());
observer.observe(chartRef.current);
return () => {
observer.disconnect();
chartInstance.current?.dispose();
chartInstance.current = null;
};
}, []);
// 更新图表数据period/data 变化时)
useEffect(() => {
if (!chartInstance.current) return;
chartInstance.current.setOption({
grid: { left: 0, right: 0, top: 10, bottom: 0 },
tooltip: {
trigger: "axis",
show: true,
confine: true,
backgroundColor: "rgba(17, 24, 39, 0.9)",
borderColor: "#374151",
textStyle: { color: "#f9fafb", fontSize: 12, fontWeight: 500 },
formatter: (params: any) => {
const d = params[0];
return `<div style="padding: 4px 8px;">
<span style="color: #9ca3af; font-size: 11px;">${d.axisValueLabel}</span><br/>
<span style="color: #10b981; font-weight: 600; font-size: 14px;">${Number(d.value).toFixed(4)}%</span>
</div>`;
},
},
xAxis: {
type: "category",
data: xLabels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: xLabels.length > 0 && xLabels.length <= 24,
color: "#9ca3af",
fontSize: 10,
fontWeight: 500,
interval: Math.max(0, Math.floor(xLabels.length / 7) - 1),
},
},
yAxis: {
type: "value",
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
series: [
{
data: chartData,
type: "line",
smooth: true,
symbol: chartData.length <= 30 ? "circle" : "none",
symbolSize: 4,
lineStyle: { color: "#10b981", width: 2 },
itemStyle: { color: "#10b981" },
areaStyle: {
color: {
type: "linear",
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: "rgba(16, 185, 129, 0.3)" },
{ offset: 1, color: "rgba(16, 185, 129, 0)" },
],
},
},
},
],
}, true);
}, [chartData, xLabels]);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8 flex flex-col gap-4 w-full shadow-md">
{/* Header */}
<div className="flex items-center gap-3 flex-shrink-0">
<Image src="/assets/tokens/usd-coin-usdc-logo-10.svg" alt="USDC" width={32} height={32} />
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">
{t("supply.usdcLendPool")}
</h1>
</div>
{/* Historical APY Section */}
<div className="flex items-start justify-between flex-shrink-0">
{/* Left - APY Display */}
<div className="flex flex-col gap-1">
<span className="text-[12px] font-bold text-text-tertiary dark:text-gray-400 leading-[16px] tracking-[1.2px] uppercase">
{t("supply.historicalApy")}
</span>
<div className="flex items-center gap-2">
<span className="text-heading-h2 font-bold text-[#10b981] dark:text-green-400 leading-[130%] tracking-[-0.01em]">
{isLoading ? "--" : currentAPY > 0 ? `${currentAPY.toFixed(4)}%` : "--"}
</span>
{!isLoading && apyData && apyData.history.length > 1 && (
<div className={`rounded-full px-2 py-0.5 flex items-center justify-center ${apyChange >= 0 ? "bg-[#e1f8ec] dark:bg-green-900/30" : "bg-red-50 dark:bg-red-900/30"}`}>
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${apyChange >= 0 ? "text-[#10b981] dark:text-green-400" : "text-red-500 dark:text-red-400"}`}>
{apyChange >= 0 ? "+" : ""}{apyChange.toFixed(4)}%
</span>
</div>
)}
</div>
</div>
{/* Right - Period Selector */}
<div className="bg-[#F9FAFB] dark:bg-gray-700 rounded-xl p-1 flex items-center gap-1">
{(["1W", "1M", "1Y"] as Period[]).map((p) => (
<Button
key={p}
size="sm"
variant={selectedPeriod === p ? "solid" : "light"}
onPress={() => setSelectedPeriod(p)}
>
{p}
</Button>
))}
</div>
</div>
{/* Chart Section — 始终渲染 div避免 echarts 实例因 DOM 销毁而失效 */}
<div className="w-full relative" style={{ height: "260px" }}>
<div
ref={chartRef}
className="w-full h-full"
style={{ background: "linear-gradient(0deg, rgba(16, 185, 129, 0.1) 0%, transparent 100%)" }}
/>
{/* Loading 遮罩 */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-gray-800/60">
<span className="text-sm text-text-tertiary dark:text-gray-400">{t("common.loading")}</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,331 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useUSDCBalance } from '@/hooks/useBalance';
import { useLendingSupply, useSuppliedBalance } from '@/hooks/useLendingSupply';
import { getTxUrl } from '@/lib/contracts';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useHealthFactor, useSupplyAPY } from '@/hooks/useHealthFactor';
import { toast } from "sonner";
import { useWalletStatus } from '@/hooks/useWalletStatus';
import TokenSelector from '@/components/common/TokenSelector';
import { useAppKit } from "@reown/appkit/react";
import { Token } from '@/lib/api/tokens';
type StablecoinType = 'USDC' | 'USDT';
export default function SupplyPanel() {
const { t } = useApp();
const [amount, setAmount] = useState("");
// 稳定币选择(用于余额显示)
const [stablecoin, setStablecoin] = useState<StablecoinType>('USDC');
const [stablecoinObj, setStablecoinObj] = useState<Token | undefined>();
// 使用统一的钱包状态 Hook
const { address, isConnected, chainId, mounted } = useWalletStatus();
const { open } = useAppKit();
// 处理稳定币选择
const handleStablecoinSelect = useCallback((token: Token) => {
setStablecoinObj(token);
setStablecoin(token.symbol as StablecoinType);
setAmount(""); // 清空输入金额
}, []);
// 优先从产品合约地址读取精度,失败时回退到 token.decimals
const usdcInputDecimals = useTokenDecimalsFromAddress(
stablecoinObj?.contractAddress,
stablecoinObj?.decimals ?? 18
);
const usdcDisplayDecimals = Math.min(usdcInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > usdcDisplayDecimals) return;
if (parseFloat(value) > parseFloat(usdcBalance)) { setAmount(truncateDecimals(usdcBalance, usdcDisplayDecimals)); return; }
setAmount(value);
};
const { formattedBalance: usdcBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useUSDCBalance();
const { formattedBalance: suppliedBalance, refetch: refetchSupplied } = useSuppliedBalance();
// 根据选择的 token 类型决定使用哪个 hook
// 目前只支持 USDC supplyYT token 选择仅用于显示
const {
status: supplyStatus,
error: supplyError,
isLoading: isSupplyLoading,
approveHash,
supplyHash,
executeApproveAndSupply,
reset: resetSupply,
} = useLendingSupply();
// 健康因子和 APY
const {
formattedHealthFactor,
status: healthStatus,
utilization,
isLoading: isHealthLoading,
refetch: refetchHealthFactor,
} = useHealthFactor();
const { apy } = useSupplyAPY();
const SUPPLY_TOAST_ID = 'lending-supply-tx';
// Approve 交易提交
useEffect(() => {
if (approveHash && supplyStatus === 'approving') {
toast.loading(t("supply.toast.approvalSubmitted"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.waitingConfirmation"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(approveHash), '_blank'),
},
});
}
}, [approveHash, supplyStatus]);
// Supply 交易提交
useEffect(() => {
if (supplyHash && supplyStatus === 'supplying') {
toast.loading(t("supply.toast.supplySubmitted"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.supplyingNow"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(supplyHash), '_blank'),
},
});
}
}, [supplyHash, supplyStatus]);
// 成功后刷新余额
useEffect(() => {
if (supplyStatus === 'success') {
toast.success(t("supply.toast.suppliedSuccess"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.suppliedSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchSupplied();
refetchHealthFactor();
const timer = setTimeout(() => { resetSupply(); setAmount(''); }, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [supplyStatus]);
// 错误提示
useEffect(() => {
if (supplyError) {
if (supplyError === 'Transaction cancelled') {
toast.dismiss(SUPPLY_TOAST_ID);
} else {
toast.error(t("supply.toast.supplyFailed"), {
id: SUPPLY_TOAST_ID,
duration: 5000,
});
}
}
}, [supplyError]);
return (
<div className="p-6 flex flex-col gap-6 flex-1">
{/* Token Balance & Supplied */}
<div className="flex flex-col gap-4">
{/* Token Balance */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-2">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.tokenBalance")}
</span>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1 flex-1">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
{/* 稳定币选择器 */}
<div className="flex-shrink-0">
<TokenSelector
selectedToken={stablecoinObj}
onSelect={handleStablecoinSelect}
filterTypes={['stablecoin']}
defaultSymbol={stablecoin}
/>
</div>
</div>
</div>
{/* Supplied - 同步显示选中的稳定币 */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.supplied")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : parseFloat(suppliedBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{stablecoin}
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : parseFloat(suppliedBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}
</span>
</div>
</div>
{/* Deposit Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-4">
{/* Deposit Label and Available */}
<div className="flex items-center gap-2">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("supply.deposit")}
</span>
<div className="flex items-center gap-2 ml-auto">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{!mounted ? `0 ${stablecoin}` : isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} ${stablecoin}`}
</span>
</div>
<Button
size="sm"
color="default"
variant="solid"
className={buttonStyles({ intent: "max" })}
onPress={() => setAmount(truncateDecimals(usdcBalance, usdcDisplayDecimals))}
>
{t("supply.max")}
</Button>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-4 h-12">
{/* Amount Input */}
<div className="flex flex-col items-end flex-1">
<input
type="text" inputMode="decimal"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder="0.00"
className="text-heading-h3 font-bold text-text-input-box dark:text-gray-500 leading-[130%] tracking-[-0.005em] text-right bg-transparent outline-none w-full"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
--
</span>
</div>
</div>
</div>
{/* Health Factor */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.healthFactor")}: {isHealthLoading ? '...' : formattedHealthFactor}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{utilization.toFixed(1)}% {t("supply.utilization")}
</span>
</div>
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${
healthStatus === 'safe' ? 'text-[#10b981] dark:text-green-400' :
healthStatus === 'warning' ? 'text-[#ffb933] dark:text-yellow-400' :
healthStatus === 'danger' ? 'text-[#ff6900] dark:text-orange-400' :
'text-[#ef4444] dark:text-red-400'
}`}>
{healthStatus === 'safe' ? t("supply.safe") :
healthStatus === 'warning' ? t("supply.warning") :
healthStatus === 'danger' ? t("supply.danger") :
t("supply.critical")}
</span>
</div>
{/* Rainbow Gradient Progress Bar */}
<div className="relative w-full h-2.5 rounded-full overflow-hidden">
{/* Background Rainbow Gradient */}
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
}}
/>
{/* Active Progress with Rainbow Gradient */}
<div
className="absolute left-0 top-0 h-full rounded-full transition-all duration-500 ease-out"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
width: `${Math.min(utilization, 100)}%`,
boxShadow: utilization > 0 ? '0 0 8px rgba(59, 130, 246, 0.5)' : 'none',
}}
/>
</div>
</div>
{/* Estimated Returns */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-2">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-300 leading-[150%]">
{t("supply.estimatedReturns")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.estApy")}
</span>
<span className="text-body-small font-bold text-[#ff6900] dark:text-orange-400 leading-[150%]">
{apy > 0 ? `${apy.toFixed(2)}%` : '--'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.estReturnsYear")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 leading-[150%]">
{amount && apy > 0
? `~ $${(parseFloat(amount) * apy / 100).toFixed(2)}`
: '~ $0.00'}
</span>
</div>
</div>
{/* Supply Button */}
<Button
isDisabled={!mounted || (isConnected && (!isValidAmount(amount) || isSupplyLoading))}
color="default"
variant="solid"
className={`${buttonStyles({ intent: "theme" })} mt-auto`}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={() => {
if (!isConnected) { open(); return; }
if (amount && parseFloat(amount) > 0) {
resetSupply();
executeApproveAndSupply(amount);
}
}}
>
{!mounted && t("common.loading")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && supplyStatus === 'idle' && !!amount && !isValidAmount(amount) && t("common.invalidAmount")}
{mounted && isConnected && supplyStatus === 'idle' && (!amount || isValidAmount(amount)) && t("supply.supply")}
{mounted && supplyStatus === 'approving' && t("common.approving")}
{mounted && supplyStatus === 'approved' && t("supply.approvedSupplying")}
{mounted && supplyStatus === 'supplying' && t("supply.supplying")}
{mounted && supplyStatus === 'success' && t("common.success")}
{mounted && supplyStatus === 'error' && t("common.failed")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,311 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useUSDCBalance } from '@/hooks/useBalance';
import { useLendingWithdraw } from '@/hooks/useLendingWithdraw';
import { useSuppliedBalance } from '@/hooks/useLendingSupply';
import { getTxUrl } from '@/lib/contracts';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
import { useHealthFactor, useSupplyAPY, useBorrowBalance } from '@/hooks/useHealthFactor';
import { toast } from 'sonner';
import { useWalletStatus } from '@/hooks/useWalletStatus';
export default function WithdrawPanel() {
const { t } = useApp();
const [amount, setAmount] = useState("");
// 使用统一的钱包状态 Hook
const { isConnected, mounted } = useWalletStatus();
const { formattedBalance: usdcBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useUSDCBalance();
const { formattedBalance: suppliedBalance, refetch: refetchSupplied } = useSuppliedBalance();
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawLoading,
withdrawHash,
executeWithdraw,
reset: resetWithdraw,
} = useLendingWithdraw();
// 健康因子和 APY
const {
formattedHealthFactor,
status: healthStatus,
utilization,
isLoading: isHealthLoading
} = useHealthFactor();
const { apy } = useSupplyAPY();
const { formattedBalance: borrowBalance } = useBorrowBalance();
const hasShownBorrowWarning = useRef(false);
// 从产品 API 获取 USDC token 信息,优先从合约地址读取精度
const usdcToken = useTokenBySymbol('USDC');
const usdcInputDecimals = useTokenDecimalsFromAddress(usdcToken?.contractAddress, usdcToken?.decimals ?? 18);
const usdcDisplayDecimals = Math.min(usdcInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > usdcDisplayDecimals) return;
if (parseFloat(value) > parseFloat(suppliedBalance)) { setAmount(truncateDecimals(suppliedBalance, usdcDisplayDecimals)); return; }
setAmount(value);
};
// 有借款时,挂载后弹出一次提示
useEffect(() => {
if (!mounted || hasShownBorrowWarning.current) return;
if (parseFloat(borrowBalance) > 0) {
toast.warning(t("supply.toast.borrowWarning"), {
description: t("supply.toast.borrowWarningDesc"),
duration: 5000,
});
hasShownBorrowWarning.current = true;
}
}, [mounted, borrowBalance]);
const WITHDRAW_TOAST_ID = 'lending-withdraw-tx';
// Withdraw 交易提交
useEffect(() => {
if (withdrawHash && withdrawStatus === 'withdrawing') {
toast.loading(t("supply.toast.withdrawSubmitted"), {
id: WITHDRAW_TOAST_ID,
description: t("supply.toast.processingWithdrawal"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(withdrawHash), '_blank'),
},
});
}
}, [withdrawHash, withdrawStatus]);
// 成功后刷新余额
useEffect(() => {
if (withdrawStatus === 'success') {
toast.success(t("supply.toast.withdrawnSuccess"), {
id: WITHDRAW_TOAST_ID,
description: t("supply.toast.withdrawnSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchSupplied();
const timer = setTimeout(() => { resetWithdraw(); setAmount(''); }, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withdrawStatus]);
// 错误提示
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.dismiss(WITHDRAW_TOAST_ID);
} else {
toast.error(t("supply.toast.withdrawFailed"), {
id: WITHDRAW_TOAST_ID,
duration: 5000,
});
}
}
}, [withdrawError]);
return (
<div className="p-6 flex flex-col gap-6 flex-1">
{/* Token Balance & Supplied */}
<div className="flex flex-col gap-4">
{/* Token Balance */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.tokenBalance")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
USDC
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
{/* Supplied */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.supplied")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : parseFloat(truncateDecimals(suppliedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
USDC
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : parseFloat(truncateDecimals(suppliedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
</div>
{/* Withdraw Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-4">
{/* Withdraw Label and Available */}
<div className="flex items-center gap-2">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("supply.withdraw")}
</span>
<div className="flex items-center gap-2 ml-auto">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{!mounted ? '0 USDC' : `${parseFloat(truncateDecimals(suppliedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} USDC`}
</span>
</div>
<Button
size="sm"
className={buttonStyles({ intent: "max" })}
onPress={() => setAmount(truncateDecimals(suppliedBalance, usdcDisplayDecimals))}
>
{t("supply.max")}
</Button>
</div>
{/* Input Row */}
<div className="flex items-center justify-between h-12">
{/* USDC Token Button */}
<div className="bg-bg-surface dark:bg-gray-800 rounded-full border border-border-normal dark:border-gray-600 p-2 flex items-center gap-2 h-12">
<Image
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
alt="USDC"
width={32}
height={32}
/>
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
USDC
</span>
</div>
{/* Amount Input */}
<div className="flex flex-col items-end">
<input
type="text" inputMode="decimal"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder="0"
className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em] text-right bg-transparent outline-none w-24"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{amount ? `$${amount}` : "--"}
</span>
</div>
</div>
</div>
{/* Health Factor */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.healthFactor")}: {isHealthLoading ? '...' : formattedHealthFactor}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{utilization.toFixed(1)}% {t("supply.utilization")}
</span>
</div>
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${
healthStatus === 'safe' ? 'text-[#10b981] dark:text-green-400' :
healthStatus === 'warning' ? 'text-[#ffb933] dark:text-yellow-400' :
healthStatus === 'danger' ? 'text-[#ff6900] dark:text-orange-400' :
'text-[#ef4444] dark:text-red-400'
}`}>
{healthStatus === 'safe' ? t("supply.safe") :
healthStatus === 'warning' ? t("supply.warning") :
healthStatus === 'danger' ? t("supply.danger") :
t("supply.critical")}
</span>
</div>
{/* Progress Bar */}
<div className="relative w-full h-2.5 rounded-full overflow-hidden">
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
}}
/>
<div
className="absolute left-0 top-0 h-full rounded-full transition-all duration-500 ease-out"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
width: `${Math.min(utilization, 100)}%`,
boxShadow: utilization > 0 ? '0 0 8px rgba(59, 130, 246, 0.5)' : 'none',
}}
/>
</div>
</div>
{/* Current APY */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-2">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-300 leading-[150%]">
{t("supply.currentReturns")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.supplyApy")}
</span>
<span className="text-body-small font-bold text-[#ff6900] dark:text-orange-400 leading-[150%]">
{apy > 0 ? `${apy.toFixed(2)}%` : '--'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.yearlyEarnings")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 leading-[150%]">
{apy > 0 && parseFloat(suppliedBalance) > 0
? `~ $${(parseFloat(suppliedBalance) * apy / 100).toFixed(2)}`
: '~ $0.00'}
</span>
</div>
</div>
{/* Withdraw Button */}
<Button
isDisabled={
!mounted ||
!isConnected ||
!isValidAmount(amount) ||
parseFloat(amount) > parseFloat(suppliedBalance) ||
isWithdrawLoading
}
className={`${buttonStyles({ intent: "theme" })} mt-auto`}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={() => {
if (amount && parseFloat(amount) > 0) {
resetWithdraw();
executeWithdraw(amount);
}
}}
>
{!mounted && t("common.loading")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && !!amount && !isValidAmount(amount) && t("common.invalidAmount")}
{mounted && isConnected && isValidAmount(amount) && parseFloat(amount) > parseFloat(suppliedBalance) && t("supply.insufficientBalance")}
{mounted && isConnected && withdrawStatus === 'idle' && (!amount || isValidAmount(amount)) && parseFloat(amount) <= parseFloat(suppliedBalance) && t("supply.withdraw")}
{mounted && withdrawStatus === 'withdrawing' && t("supply.withdrawing")}
{mounted && withdrawStatus === 'success' && t("common.success")}
{mounted && withdrawStatus === 'error' && t("common.failed")}
</Button>
</div>
);
}