init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
165
webapp/components/lending/BorrowMarket.tsx
Normal file
165
webapp/components/lending/BorrowMarket.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
webapp/components/lending/LendingHeader.tsx
Normal file
153
webapp/components/lending/LendingHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
webapp/components/lending/LendingPlaceholder.tsx
Normal file
80
webapp/components/lending/LendingPlaceholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
webapp/components/lending/LendingStats.tsx
Normal file
216
webapp/components/lending/LendingStats.tsx
Normal 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 },
|
||||
});
|
||||
|
||||
// 链上供应利率(年化 APR,1e18 精度)
|
||||
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;
|
||||
|
||||
// 计算供应 APY(getSupplyRate 返回年化 APR,1e18 精度)
|
||||
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>
|
||||
);
|
||||
}
|
||||
338
webapp/components/lending/repay/RepayBorrowDebt.tsx
Normal file
338
webapp/components/lending/repay/RepayBorrowDebt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
webapp/components/lending/repay/RepayHeader.tsx
Normal file
102
webapp/components/lending/repay/RepayHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
webapp/components/lending/repay/RepayPoolStats.tsx
Normal file
88
webapp/components/lending/repay/RepayPoolStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
webapp/components/lending/repay/RepayStats.tsx
Normal file
93
webapp/components/lending/repay/RepayStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
webapp/components/lending/repay/RepaySupplyCollateral.tsx
Normal file
309
webapp/components/lending/repay/RepaySupplyCollateral.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
webapp/components/lending/supply/SupplyContent.tsx
Normal file
178
webapp/components/lending/supply/SupplyContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
331
webapp/components/lending/supply/SupplyPanel.tsx
Normal file
331
webapp/components/lending/supply/SupplyPanel.tsx
Normal 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 supply,YT 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>
|
||||
);
|
||||
}
|
||||
311
webapp/components/lending/supply/WithdrawPanel.tsx
Normal file
311
webapp/components/lending/supply/WithdrawPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user