init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
105
webapp/components/alp/ALPStatsCards.tsx
Normal file
105
webapp/components/alp/ALPStatsCards.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useReadContracts } from 'wagmi';
|
||||
import { useAccount } from 'wagmi';
|
||||
import { formatUnits } from 'viem';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { getContractAddress, abis } from '@/lib/contracts';
|
||||
|
||||
function formatUSD(v: number): string {
|
||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v >= 1_000) return `$${(v / 1_000).toFixed(2)}K`;
|
||||
return `$${v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function ALPStatsCards() {
|
||||
const { t } = useApp();
|
||||
const { chainId } = useAccount();
|
||||
|
||||
const poolManagerAddress = chainId ? getContractAddress('YTPoolManager', chainId) : undefined;
|
||||
const lpTokenAddress = chainId ? getContractAddress('YTLPToken', chainId) : undefined;
|
||||
|
||||
const { data: tvlData, isLoading } = useReadContracts({
|
||||
contracts: [
|
||||
{
|
||||
address: poolManagerAddress,
|
||||
abi: abis.YTPoolManager as any,
|
||||
functionName: 'getAumInUsdy',
|
||||
args: [true],
|
||||
chainId: chainId,
|
||||
},
|
||||
{
|
||||
address: lpTokenAddress,
|
||||
abi: abis.YTLPToken as any,
|
||||
functionName: 'totalSupply',
|
||||
args: [],
|
||||
chainId: chainId,
|
||||
},
|
||||
],
|
||||
query: { refetchInterval: 30000, enabled: !!poolManagerAddress && !!lpTokenAddress && !!chainId },
|
||||
});
|
||||
|
||||
const aumRaw = tvlData?.[0]?.result as bigint | undefined;
|
||||
const supplyRaw = tvlData?.[1]?.result as bigint | undefined;
|
||||
const tvlValue = aumRaw ? formatUSD(parseFloat(formatUnits(aumRaw, 18))) : (isLoading ? '...' : '--');
|
||||
const alpPrice = aumRaw && supplyRaw && supplyRaw > 0n
|
||||
? `$${(parseFloat(formatUnits(aumRaw, 18)) / parseFloat(formatUnits(supplyRaw, 18))).toFixed(4)}`
|
||||
: (isLoading ? '...' : '--');
|
||||
|
||||
// Fetch APR from backend (requires historical snapshots)
|
||||
const { data: aprData } = useQuery({
|
||||
queryKey: ['alp-stats'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/alp/stats');
|
||||
const json = await res.json();
|
||||
return json.data as { poolAPR: number; rewardAPR: number };
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000, // refresh every 5 min
|
||||
});
|
||||
|
||||
const poolAPR = aprData?.poolAPR != null
|
||||
? `${aprData.poolAPR.toFixed(4)}%`
|
||||
: '--';
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: t("stats.totalValueLocked"),
|
||||
value: tvlValue,
|
||||
isGreen: false,
|
||||
},
|
||||
{
|
||||
label: t("alp.price"),
|
||||
value: alpPrice,
|
||||
isGreen: false,
|
||||
},
|
||||
{
|
||||
label: t("alp.poolAPR"),
|
||||
value: poolAPR,
|
||||
isGreen: aprData?.poolAPR != null && aprData.poolAPR > 0,
|
||||
},
|
||||
{
|
||||
label: t("alp.rewardAPR"),
|
||||
value: "--",
|
||||
isGreen: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-bg-subtle dark:bg-gray-800 rounded-2xl border border-border-gray dark:border-gray-700 px-4 md:px-6 py-4 flex flex-col gap-2"
|
||||
>
|
||||
<div className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">
|
||||
{stat.label}
|
||||
</div>
|
||||
<div className={`text-xl md:text-[32px] font-bold leading-[130%] tracking-[-0.01em] ${stat.isGreen ? 'text-[#10b981]' : 'text-text-primary dark:text-white'}`}>
|
||||
{stat.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
317
webapp/components/alp/LiquidityAllocationTable.tsx
Normal file
317
webapp/components/alp/LiquidityAllocationTable.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useReadContracts } from 'wagmi';
|
||||
import { useAccount } from 'wagmi';
|
||||
import { formatUnits } from 'viem';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { getContractAddress, abis } from '@/lib/contracts';
|
||||
|
||||
function formatUSD(v: number): string {
|
||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v >= 1_000) return `$${(v / 1_000).toFixed(2)}K`;
|
||||
return `$${v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function LiquidityAllocationTable({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
const { t } = useApp();
|
||||
const { chainId } = useAccount();
|
||||
|
||||
// Fetch all products from backend; filter non-stablecoins to build YT token list
|
||||
const { data: products = [] } = useQuery<any[]>({
|
||||
queryKey: ['products'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/fundmarket/products?token_list=1');
|
||||
const json = await res.json();
|
||||
return json.data ?? [];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Build dynamic YT token list from DB products (only yt_token role)
|
||||
// Use contractAddress and chainId directly from product data
|
||||
const ytTokens = useMemo(() =>
|
||||
products
|
||||
.filter((p: any) => p.token_role === 'yt_token' && p.contractAddress)
|
||||
.map((p: any) => ({
|
||||
key: p.tokenSymbol as string,
|
||||
address: p.contractAddress as string,
|
||||
chainId: p.chainId as number,
|
||||
meta: p,
|
||||
})),
|
||||
[products]);
|
||||
|
||||
// Derive target chainId from products, fallback to connected wallet
|
||||
const targetChainId = ytTokens[0]?.chainId ?? chainId;
|
||||
|
||||
// System contract addresses from registry
|
||||
const ytVaultAddr = targetChainId ? getContractAddress('YTVault', targetChainId) : undefined;
|
||||
const usdcAddr = targetChainId ? getContractAddress('USDC', targetChainId) : undefined;
|
||||
const priceFeedAddr = targetChainId ? getContractAddress('YTPriceFeed', targetChainId) : undefined;
|
||||
|
||||
const N = ytTokens.length;
|
||||
const addressesReady = !!(ytVaultAddr && usdcAddr && priceFeedAddr && N > 0 && ytTokens.every(t => t.address));
|
||||
|
||||
// Dynamic batch on-chain reads — indices scale with N = ytTokens.length:
|
||||
// [0] YTVault.getPoolValue(true)
|
||||
// [1..N] YTVault.usdyAmounts(ytToken[i])
|
||||
// [N+1..2N] YTPriceFeed.getPrice(ytToken[i], false)
|
||||
// [2N+1] USDC.balanceOf(ytVaultAddr)
|
||||
// [2N+2] YTPriceFeed.getPrice(usdcAddr, false)
|
||||
// [2N+3] YTVault.totalTokenWeights()
|
||||
// [2N+4..3N+3] YTVault.tokenWeights(ytToken[i])
|
||||
// [3N+4] YTVault.tokenWeights(usdcAddr)
|
||||
const { data: chainData, refetch: refetchChain } = useReadContracts({
|
||||
contracts: [
|
||||
{
|
||||
address: ytVaultAddr,
|
||||
abi: abis.YTVault as any,
|
||||
functionName: 'getPoolValue',
|
||||
args: [true],
|
||||
chainId: targetChainId,
|
||||
} as const,
|
||||
...ytTokens.map(tk => ({
|
||||
address: ytVaultAddr,
|
||||
abi: abis.YTVault as any,
|
||||
functionName: 'usdyAmounts',
|
||||
args: [tk.address],
|
||||
chainId: targetChainId,
|
||||
} as const)),
|
||||
...ytTokens.map(tk => ({
|
||||
address: priceFeedAddr,
|
||||
abi: abis.YTPriceFeed as any,
|
||||
functionName: 'getPrice',
|
||||
args: [tk.address, false],
|
||||
chainId: targetChainId,
|
||||
} as const)),
|
||||
{
|
||||
address: usdcAddr,
|
||||
abi: abis.USDY as any,
|
||||
functionName: 'balanceOf',
|
||||
args: [ytVaultAddr],
|
||||
chainId: targetChainId,
|
||||
} as const,
|
||||
{
|
||||
address: priceFeedAddr,
|
||||
abi: abis.YTPriceFeed as any,
|
||||
functionName: 'getPrice',
|
||||
args: [usdcAddr, false],
|
||||
chainId: targetChainId,
|
||||
} as const,
|
||||
{
|
||||
address: ytVaultAddr,
|
||||
abi: abis.YTVault as any,
|
||||
functionName: 'totalTokenWeights',
|
||||
chainId: targetChainId,
|
||||
} as const,
|
||||
...ytTokens.map(tk => ({
|
||||
address: ytVaultAddr,
|
||||
abi: abis.YTVault as any,
|
||||
functionName: 'tokenWeights',
|
||||
args: [tk.address],
|
||||
chainId: targetChainId,
|
||||
} as const)),
|
||||
{
|
||||
address: ytVaultAddr,
|
||||
abi: abis.YTVault as any,
|
||||
functionName: 'tokenWeights',
|
||||
args: [usdcAddr],
|
||||
chainId: targetChainId,
|
||||
} as const,
|
||||
],
|
||||
query: { refetchInterval: 30000, enabled: addressesReady },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshTrigger) refetchChain();
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// Parse results with dynamic indices
|
||||
const totalPoolRaw = chainData?.[0]?.result as bigint | undefined;
|
||||
const totalWeightsRaw = chainData?.[2 * N + 3]?.result as bigint | undefined;
|
||||
const totalPool = totalPoolRaw ? parseFloat(formatUnits(totalPoolRaw, 18)) : 0;
|
||||
const totalWeights = totalWeightsRaw ? Number(totalWeightsRaw) : 0;
|
||||
|
||||
const ytAllocations = ytTokens.map((token, i) => {
|
||||
const usdyRaw = chainData?.[1 + i]?.result as bigint | undefined;
|
||||
const priceRaw = chainData?.[N + 1 + i]?.result as bigint | undefined;
|
||||
const twRaw = chainData?.[2 * N + 4 + i]?.result as bigint | undefined;
|
||||
|
||||
const poolSizeUSD = usdyRaw ? parseFloat(formatUnits(usdyRaw, 18)) : 0;
|
||||
const ytPrice = priceRaw ? parseFloat(formatUnits(priceRaw, 30)) : 0;
|
||||
const balance = ytPrice > 0 ? poolSizeUSD / ytPrice : 0;
|
||||
const currentWeight = totalPool > 0 ? poolSizeUSD / totalPool * 100 : 0;
|
||||
const targetWeight = totalWeights > 0 && twRaw != null ? Number(twRaw) / totalWeights * 100 : 0;
|
||||
|
||||
return {
|
||||
key: token.key,
|
||||
name: token.meta?.name ?? token.key,
|
||||
category: token.meta?.category ?? '--',
|
||||
iconUrl: token.meta?.iconUrl,
|
||||
poolSizeUSD,
|
||||
balance,
|
||||
ytPrice,
|
||||
currentWeight,
|
||||
targetWeight,
|
||||
isStable: false,
|
||||
};
|
||||
});
|
||||
|
||||
const usdcRaw = chainData?.[2 * N + 1]?.result as bigint | undefined;
|
||||
const usdcPriceRaw = chainData?.[2 * N + 2]?.result as bigint | undefined;
|
||||
const usdcTWRaw = chainData?.[3 * N + 4]?.result as bigint | undefined;
|
||||
const usdcBalance = usdcRaw ? parseFloat(formatUnits(usdcRaw, 18)) : 0;
|
||||
const usdcPrice = usdcPriceRaw ? parseFloat(formatUnits(usdcPriceRaw, 30)) : 0;
|
||||
const usdcPoolSize = usdcBalance * usdcPrice;
|
||||
const usdcCurWeight = totalPool > 0 ? usdcPoolSize / totalPool * 100 : 0;
|
||||
const usdcTgtWeight = totalWeights > 0 && usdcTWRaw != null ? Number(usdcTWRaw) / totalWeights * 100 : 0;
|
||||
const usdcMeta = products.find((p: any) => p.tokenSymbol === 'USDC');
|
||||
|
||||
const allocations = [
|
||||
...ytAllocations,
|
||||
{
|
||||
key: 'USDC',
|
||||
name: usdcMeta?.name ?? 'USD Coin',
|
||||
category: usdcMeta?.category ?? 'Stablecoin',
|
||||
iconUrl: usdcMeta?.iconUrl,
|
||||
poolSizeUSD: usdcPoolSize,
|
||||
balance: usdcBalance,
|
||||
ytPrice: usdcPrice,
|
||||
currentWeight: usdcCurWeight,
|
||||
targetWeight: usdcTgtWeight,
|
||||
isStable: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Title */}
|
||||
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white">
|
||||
{t("alp.liquidityAllocation")}
|
||||
</h2>
|
||||
|
||||
{/* 移动端:卡片布局 */}
|
||||
<div className="md:hidden flex flex-col gap-3">
|
||||
{allocations.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="bg-bg-surface dark:bg-gray-800 rounded-2xl border border-border-gray dark:border-gray-700 p-4 flex flex-col gap-3"
|
||||
>
|
||||
{/* Token Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src={item.iconUrl || '/assets/tokens/default.svg'}
|
||||
alt={item.key}
|
||||
width={36}
|
||||
height={36}
|
||||
className="rounded-full"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">{item.name}</span>
|
||||
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{item.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{t("alp.poolSize")}</span>
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{item.poolSizeUSD > 0 ? formatUSD(item.poolSizeUSD) : '--'}
|
||||
</span>
|
||||
<span className="text-caption-tiny text-[#6b7280] dark:text-gray-400">
|
||||
{item.balance > 0 ? `${item.balance.toFixed(2)} ${item.key}` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">Weight</span>
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{totalPool > 0 ? `${item.currentWeight.toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">
|
||||
{item.targetWeight > 0 ? `→ ${item.targetWeight.toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{t("alp.currentPrice")}</span>
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{item.ytPrice > 0 ? `$${item.ytPrice.toFixed(4)}` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 桌面端:表格布局 */}
|
||||
<div className="hidden md:block bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 overflow-hidden">
|
||||
<div className="flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex border-b border-border-gray dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex-1 px-6 py-4">
|
||||
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.token")}</div>
|
||||
</div>
|
||||
<div className="flex-1 px-6 py-4">
|
||||
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.poolSize")}</div>
|
||||
</div>
|
||||
<div className="flex-1 px-6 py-4">
|
||||
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">Weight (Current / Target)</div>
|
||||
</div>
|
||||
<div className="flex-1 px-6 py-4">
|
||||
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.currentPrice")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{allocations.map((item) => (
|
||||
<div key={item.key} className="flex items-center border-b border-border-gray dark:border-gray-700 last:border-b-0">
|
||||
<div className="flex-1 px-6 py-4 flex items-center gap-3">
|
||||
<Image
|
||||
src={item.iconUrl || '/assets/tokens/default.svg'}
|
||||
alt={item.key}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">{item.name}</span>
|
||||
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">{item.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{item.poolSizeUSD > 0 ? formatUSD(item.poolSizeUSD) : '--'}
|
||||
</span>
|
||||
<span className="text-caption-tiny font-regular text-[#6b7280] dark:text-gray-400">
|
||||
{item.balance > 0 ? `${item.balance.toFixed(2)} ${item.key}` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 px-6 py-4">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{totalPool > 0 ? (
|
||||
<>
|
||||
{item.currentWeight.toFixed(1)}%
|
||||
<span className="font-regular text-text-tertiary dark:text-gray-400">
|
||||
{' / '}{item.targetWeight > 0 ? `${item.targetWeight.toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
</>
|
||||
) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 px-6 py-4">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{item.ytPrice > 0 ? `$${item.ytPrice.toFixed(4)}` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
541
webapp/components/alp/PoolDepositPanel.tsx
Normal file
541
webapp/components/alp/PoolDepositPanel.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { Button, Tabs, Tab } from "@heroui/react";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { buttonStyles } from "@/lib/buttonStyles";
|
||||
import { useAccount, useReadContract } from 'wagmi';
|
||||
import { parseUnits, formatUnits } from 'viem';
|
||||
import { useTokenBalance, useYTLPBalance } from '@/hooks/useBalance';
|
||||
import { usePoolDeposit } from '@/hooks/usePoolDeposit';
|
||||
import { usePoolWithdraw } from '@/hooks/usePoolWithdraw';
|
||||
import { toast } from "sonner";
|
||||
import TokenSelector from '@/components/common/TokenSelector';
|
||||
import { Token } from '@/lib/api/tokens';
|
||||
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
|
||||
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
|
||||
import { getContractAddress, abis } from '@/lib/contracts';
|
||||
|
||||
|
||||
interface PoolDepositPanelProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function PoolDepositPanel({ onSuccess }: PoolDepositPanelProps) {
|
||||
const { t } = useApp();
|
||||
const [activeTab, setActiveTab] = useState<string>("deposit");
|
||||
const [selectedToken, setSelectedToken] = useState<string>('USDC');
|
||||
const [selectedTokenObj, setSelectedTokenObj] = useState<Token | undefined>();
|
||||
const [outputToken, setOutputToken] = useState<string>('USDC');
|
||||
const [outputTokenObj, setOutputTokenObj] = useState<Token | undefined>();
|
||||
const [depositAmount, setDepositAmount] = useState<string>("");
|
||||
const [withdrawAmount, setWithdrawAmount] = useState<string>("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [withdrawCooldownLeft, setWithdrawCooldownLeft] = useState(0); // seconds remaining
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Track 15-min withdraw cooldown after deposit
|
||||
useEffect(() => {
|
||||
const COOLDOWN_MS = 15 * 60 * 1000;
|
||||
const tick = () => {
|
||||
const lastDeposit = parseInt(localStorage.getItem('alp_last_deposit_time') ?? '0', 10);
|
||||
if (!lastDeposit) { setWithdrawCooldownLeft(0); return; }
|
||||
const remaining = Math.ceil((lastDeposit + COOLDOWN_MS - Date.now()) / 1000);
|
||||
setWithdrawCooldownLeft(remaining > 0 ? remaining : 0);
|
||||
};
|
||||
tick();
|
||||
const interval = setInterval(tick, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 处理存入 Token 选择
|
||||
const handleYTTokenSelect = useCallback((token: Token) => {
|
||||
setSelectedTokenObj(token);
|
||||
setSelectedToken(token.symbol);
|
||||
setDepositAmount("");
|
||||
}, []);
|
||||
|
||||
// 处理输出 Token 选择
|
||||
const handleOutputTokenSelect = useCallback((token: Token) => {
|
||||
setOutputTokenObj(token);
|
||||
setOutputToken(token.symbol);
|
||||
setWithdrawAmount("");
|
||||
}, []);
|
||||
|
||||
// Web3 集成
|
||||
const { address, isConnected, chainId } = useAccount();
|
||||
|
||||
// 优先从产品合约地址读取精度,失败时回退到 token.decimals
|
||||
const depositInputDecimals = useTokenDecimalsFromAddress(
|
||||
selectedTokenObj?.contractAddress,
|
||||
selectedTokenObj?.decimals ?? 18
|
||||
);
|
||||
const depositDisplayDecimals = Math.min(depositInputDecimals, 6);
|
||||
const ytLPAddress = chainId ? getContractAddress('YTLPToken', chainId) : undefined;
|
||||
const poolManagerAddress = chainId ? getContractAddress('YTPoolManager', chainId) : undefined;
|
||||
const withdrawDecimals = useTokenDecimalsFromAddress(ytLPAddress, 18);
|
||||
const withdrawDisplayDecimals = Math.min(withdrawDecimals, 6);
|
||||
const outputDecimals = useTokenDecimalsFromAddress(outputTokenObj?.contractAddress, outputTokenObj?.decimals ?? 18);
|
||||
|
||||
// 存入估算:getAddLiquidityOutput(token, amount) → [usdyAmount, ytLPMintAmount]
|
||||
const depositAmountWei = depositAmount !== '' && parseFloat(depositAmount) > 0 && selectedTokenObj
|
||||
? parseUnits(depositAmount, depositInputDecimals)
|
||||
: undefined;
|
||||
const { data: depositEstData, isLoading: isDepositEstLoading } = useReadContract({
|
||||
address: poolManagerAddress,
|
||||
abi: abis.YTPoolManager as any,
|
||||
functionName: 'getAddLiquidityOutput',
|
||||
args: [selectedTokenObj?.contractAddress as `0x${string}`, depositAmountWei ?? 0n],
|
||||
query: { enabled: !!poolManagerAddress && !!selectedTokenObj?.contractAddress && !!depositAmountWei },
|
||||
});
|
||||
const estLPTokens = depositEstData
|
||||
? parseFloat(formatUnits((depositEstData as [bigint, bigint])[1], 18))
|
||||
: null;
|
||||
|
||||
// 提出估算:getRemoveLiquidityOutput(tokenOut, ytLPAmount) → [usdyAmount, amountOut]
|
||||
const withdrawAmountWei = withdrawAmount !== '' && parseFloat(withdrawAmount) > 0
|
||||
? parseUnits(withdrawAmount, withdrawDecimals)
|
||||
: undefined;
|
||||
const { data: withdrawEstData, isLoading: isWithdrawEstLoading } = useReadContract({
|
||||
address: poolManagerAddress,
|
||||
abi: abis.YTPoolManager as any,
|
||||
functionName: 'getRemoveLiquidityOutput',
|
||||
args: [outputTokenObj?.contractAddress as `0x${string}`, withdrawAmountWei ?? 0n],
|
||||
query: { enabled: !!poolManagerAddress && !!outputTokenObj?.contractAddress && !!withdrawAmountWei },
|
||||
});
|
||||
const estAmountOut = withdrawEstData
|
||||
? parseFloat(formatUnits((withdrawEstData as [bigint, bigint])[1], outputDecimals))
|
||||
: null;
|
||||
|
||||
const { formattedBalance: depositBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useTokenBalance(
|
||||
selectedTokenObj?.contractAddress,
|
||||
depositInputDecimals
|
||||
);
|
||||
const { formattedBalance: outputTokenBalance } = useTokenBalance(
|
||||
outputTokenObj?.contractAddress,
|
||||
outputDecimals
|
||||
);
|
||||
const { formattedBalance: lpBalance, refetch: refetchLP } = useYTLPBalance();
|
||||
|
||||
// Deposit hooks
|
||||
const {
|
||||
status: depositStatus,
|
||||
error: depositError,
|
||||
isLoading: isDepositLoading,
|
||||
approveHash: depositApproveHash,
|
||||
depositHash,
|
||||
executeApproveAndDeposit,
|
||||
reset: resetDeposit,
|
||||
} = usePoolDeposit();
|
||||
|
||||
// Withdraw hooks
|
||||
const {
|
||||
status: withdrawStatus,
|
||||
error: withdrawError,
|
||||
isLoading: isWithdrawLoading,
|
||||
approveHash: withdrawApproveHash,
|
||||
withdrawHash: withdrawTxHash,
|
||||
executeApproveAndWithdraw,
|
||||
reset: resetWithdraw,
|
||||
} = usePoolWithdraw();
|
||||
|
||||
// 存款成功后刷新余额,并记录 15 分钟提现冷却开始时间
|
||||
useEffect(() => {
|
||||
if (depositStatus === 'success') {
|
||||
toast.success(t("alp.toast.liquidityAdded"), {
|
||||
description: t("alp.toast.liquidityAddedDesc"),
|
||||
duration: 5000,
|
||||
});
|
||||
localStorage.setItem('alp_last_deposit_time', Date.now().toString());
|
||||
refetchBalance();
|
||||
refetchLP();
|
||||
onSuccess?.();
|
||||
const timer = setTimeout(() => {
|
||||
resetDeposit();
|
||||
setDepositAmount("");
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [depositStatus]);
|
||||
|
||||
// 提款成功后刷新余额
|
||||
useEffect(() => {
|
||||
if (withdrawStatus === 'success') {
|
||||
toast.success(t("alp.toast.liquidityRemoved"), {
|
||||
description: t("alp.toast.liquidityRemovedDesc"),
|
||||
duration: 5000,
|
||||
});
|
||||
refetchBalance();
|
||||
refetchLP();
|
||||
onSuccess?.();
|
||||
const timer = setTimeout(() => {
|
||||
resetWithdraw();
|
||||
setWithdrawAmount("");
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [withdrawStatus]);
|
||||
|
||||
// 显示错误提示
|
||||
useEffect(() => {
|
||||
if (depositError) {
|
||||
if (depositError === 'Transaction cancelled') {
|
||||
toast.warning(t("alp.toast.txCancelled"), {
|
||||
description: t("alp.toast.txCancelledDesc"),
|
||||
duration: 3000,
|
||||
});
|
||||
} else {
|
||||
toast.error(t("alp.toast.depositFailed"), {
|
||||
description: depositError,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [depositError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (withdrawError) {
|
||||
if (withdrawError === 'Transaction cancelled') {
|
||||
toast.warning(t("alp.toast.txCancelled"), {
|
||||
description: t("alp.toast.txCancelledDesc"),
|
||||
duration: 3000,
|
||||
});
|
||||
} else {
|
||||
toast.error(t("alp.toast.withdrawFailed"), {
|
||||
description: withdrawError,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [withdrawError]);
|
||||
|
||||
// 显示交易hash toast
|
||||
useEffect(() => {
|
||||
if (depositApproveHash && depositStatus === 'approving') {
|
||||
toast.info(t("alp.toast.approvalSubmitted"), {
|
||||
description: t("alp.toast.approvingTokens"),
|
||||
action: {
|
||||
label: t("alp.toast.viewTx"),
|
||||
onClick: () => window.open(`https://testnet.bscscan.com/tx/${depositApproveHash}`, '_blank'),
|
||||
},
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
}, [depositApproveHash, depositStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (depositHash && depositStatus === 'depositing') {
|
||||
toast.info(t("alp.toast.depositSubmitted"), {
|
||||
description: t("alp.toast.addingLiquidity"),
|
||||
action: {
|
||||
label: t("alp.toast.viewTx"),
|
||||
onClick: () => window.open(`https://testnet.bscscan.com/tx/${depositHash}`, '_blank'),
|
||||
},
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
}, [depositHash, depositStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (withdrawApproveHash && withdrawStatus === 'approving') {
|
||||
toast.info(t("alp.toast.approvalSubmitted"), {
|
||||
description: t("alp.toast.approvingLP"),
|
||||
action: {
|
||||
label: t("alp.toast.viewTx"),
|
||||
onClick: () => window.open(`https://testnet.bscscan.com/tx/${withdrawApproveHash}`, '_blank'),
|
||||
},
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
}, [withdrawApproveHash, withdrawStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (withdrawTxHash && withdrawStatus === 'withdrawing') {
|
||||
toast.info(t("alp.toast.withdrawSubmitted"), {
|
||||
description: t("alp.toast.removingLiquidity"),
|
||||
action: {
|
||||
label: t("alp.toast.viewTx"),
|
||||
onClick: () => window.open(`https://testnet.bscscan.com/tx/${withdrawTxHash}`, '_blank'),
|
||||
},
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
}, [withdrawTxHash, withdrawStatus]);
|
||||
|
||||
// 切换Tab时重置错误状态和输入框
|
||||
useEffect(() => {
|
||||
resetDeposit();
|
||||
resetWithdraw();
|
||||
setDepositAmount("");
|
||||
setWithdrawAmount("");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]);
|
||||
|
||||
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 handleDepositPercentage = (pct: number) => {
|
||||
const bal = parseFloat(depositBalance);
|
||||
if (bal > 0) setDepositAmount(truncateDecimals((bal * pct / 100).toString(), depositDisplayDecimals));
|
||||
};
|
||||
|
||||
const handleWithdrawPercentage = (pct: number) => {
|
||||
const bal = parseFloat(lpBalance);
|
||||
if (bal > 0) setWithdrawAmount(truncateDecimals((bal * pct / 100).toString(), withdrawDisplayDecimals));
|
||||
};
|
||||
|
||||
const pctBtnClass = "flex-1 sm:flex-none bg-[#e5e7eb] dark:bg-gray-600 rounded-full px-2 sm:px-2.5 h-[22px] text-[10px] font-medium text-text-primary dark:text-white hover:bg-[#d1d5db] dark:hover:bg-gray-500 transition-colors";
|
||||
|
||||
const handleDepositAmountChange = (value: string) => {
|
||||
if (value === '') { setDepositAmount(value); return; }
|
||||
if (!/^\d*\.?\d*$/.test(value)) return;
|
||||
const parts = value.split('.');
|
||||
if (parts.length > 1 && parts[1].length > depositDisplayDecimals) return;
|
||||
if (parseFloat(value) > parseFloat(depositBalance)) { setDepositAmount(truncateDecimals(depositBalance, depositDisplayDecimals)); return; }
|
||||
setDepositAmount(value);
|
||||
};
|
||||
|
||||
const handleWithdrawAmountChange = (value: string) => {
|
||||
if (value === '') { setWithdrawAmount(value); return; }
|
||||
if (!/^\d*\.?\d*$/.test(value)) return;
|
||||
const parts = value.split('.');
|
||||
if (parts.length > 1 && parts[1].length > withdrawDisplayDecimals) return;
|
||||
if (parseFloat(value) > parseFloat(lpBalance)) { setWithdrawAmount(truncateDecimals(lpBalance, withdrawDisplayDecimals)); return; }
|
||||
setWithdrawAmount(value);
|
||||
};
|
||||
|
||||
const handleDeposit = () => {
|
||||
if (depositAmount && parseFloat(depositAmount) > 0 && selectedTokenObj) {
|
||||
executeApproveAndDeposit(selectedTokenObj.contractAddress, depositInputDecimals, depositAmount);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = () => {
|
||||
if (withdrawAmount && parseFloat(withdrawAmount) > 0 && outputTokenObj) {
|
||||
executeApproveAndWithdraw(outputTokenObj.contractAddress, outputTokenObj.decimals ?? 18, withdrawAmount);
|
||||
}
|
||||
};
|
||||
|
||||
const currentStatus = activeTab === 'deposit' ? depositStatus : withdrawStatus;
|
||||
const isLoading = activeTab === 'deposit' ? isDepositLoading : isWithdrawLoading;
|
||||
const currentAmount = activeTab === 'deposit' ? depositAmount : withdrawAmount;
|
||||
|
||||
return (
|
||||
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden h-full">
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-6 p-6 flex-1">
|
||||
{/* Title */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white">
|
||||
{t("alp.liquidityPool")}
|
||||
</h2>
|
||||
<p className="text-body-small text-text-tertiary dark:text-gray-400">
|
||||
{t("alp.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={(key) => setActiveTab(key as string)}
|
||||
classNames={{
|
||||
base: "w-full",
|
||||
tabList: "w-full bg-bg-subtle dark:bg-gray-700 p-1 rounded-xl",
|
||||
tab: "h-10",
|
||||
cursor: "bg-white dark:bg-gray-600",
|
||||
tabContent: "group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
|
||||
}}
|
||||
>
|
||||
<Tab key="deposit" title={t("alp.deposit")}>
|
||||
{/* Deposit Panel */}
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{/* Input Area */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<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-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
|
||||
{t("mintSwap.deposit")}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 sm:hidden">
|
||||
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
|
||||
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
|
||||
{!mounted ? '0' : (isBalanceLoading ? '...' : parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals }))} {selectedToken}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => handleDepositPercentage(25)} className={pctBtnClass}>25%</button>
|
||||
<button onClick={() => handleDepositPercentage(50)} className={pctBtnClass}>50%</button>
|
||||
<button onClick={() => handleDepositPercentage(75)} className={pctBtnClass}>75%</button>
|
||||
<button onClick={() => handleDepositPercentage(100)} className={pctBtnClass}>{t("mintSwap.max")}</button>
|
||||
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} className="ml-1 hidden sm:block" />
|
||||
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400 hidden sm:block">
|
||||
{!mounted ? '0' : (isBalanceLoading ? '...' : parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals }))} {selectedToken}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start flex-1">
|
||||
<input
|
||||
type="text" inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={depositAmount}
|
||||
onChange={(e) => handleDepositAmountChange(e.target.value)}
|
||||
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
|
||||
/>
|
||||
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
|
||||
{depositAmount ? `≈ $${depositAmount}` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<TokenSelector
|
||||
selectedToken={selectedTokenObj}
|
||||
onSelect={handleYTTokenSelect}
|
||||
filterTypes={['stablecoin', 'yield-token']}
|
||||
defaultSymbol={selectedToken}
|
||||
/>
|
||||
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
|
||||
{!mounted ? `0 ${selectedToken}` : (isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals })} ${selectedToken}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pool Info */}
|
||||
<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-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400">
|
||||
{t("alp.estimatedLP")}
|
||||
</span>
|
||||
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400">
|
||||
{estLPTokens != null
|
||||
? `≈ ${estLPTokens.toLocaleString('en-US', { maximumFractionDigits: 6 })} LP`
|
||||
: depositAmount ? (isDepositEstLoading ? '...' : '--') : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab key="withdraw" title={t("alp.withdraw")}>
|
||||
{/* Withdraw Panel */}
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{/* Input Area */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<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-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
|
||||
{t("alp.amountToWithdraw")}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 sm:hidden">
|
||||
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
|
||||
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
|
||||
{parseFloat(truncateDecimals(lpBalance, withdrawDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: withdrawDisplayDecimals })} YT LP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => handleWithdrawPercentage(25)} className={pctBtnClass}>25%</button>
|
||||
<button onClick={() => handleWithdrawPercentage(50)} className={pctBtnClass}>50%</button>
|
||||
<button onClick={() => handleWithdrawPercentage(75)} className={pctBtnClass}>75%</button>
|
||||
<button onClick={() => handleWithdrawPercentage(100)} className={pctBtnClass}>{t("mintSwap.max")}</button>
|
||||
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} className="ml-1 hidden sm:block" />
|
||||
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400 hidden sm:block">
|
||||
{parseFloat(truncateDecimals(lpBalance, withdrawDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: withdrawDisplayDecimals })} YT LP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start flex-1">
|
||||
<input
|
||||
type="text" inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => handleWithdrawAmountChange(e.target.value)}
|
||||
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
|
||||
/>
|
||||
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
|
||||
{withdrawAmount ? `≈ ${withdrawAmount} LP` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<TokenSelector
|
||||
selectedToken={outputTokenObj}
|
||||
onSelect={handleOutputTokenSelect}
|
||||
filterTypes={['stablecoin', 'yield-token']}
|
||||
defaultSymbol={outputToken}
|
||||
/>
|
||||
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
|
||||
{!mounted ? `0 ${outputToken}` : `${parseFloat(truncateDecimals(outputTokenBalance, 6)).toLocaleString('en-US', { maximumFractionDigits: 6 })} ${outputToken}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Withdraw Info */}
|
||||
<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-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400">
|
||||
{t("alp.youWillReceive")}
|
||||
</span>
|
||||
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400">
|
||||
{estAmountOut != null
|
||||
? `≈ ${estAmountOut.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${outputToken}`
|
||||
: withdrawAmount ? (isWithdrawEstLoading ? '...' : '--') : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{/* Submit Button */}
|
||||
{(() => {
|
||||
const amountInvalid = !!currentAmount && !isValidAmount(currentAmount);
|
||||
return (
|
||||
<Button
|
||||
isDisabled={!mounted || !isConnected || !isValidAmount(currentAmount) || isLoading || (activeTab === 'withdraw' && withdrawCooldownLeft > 0)}
|
||||
color="default"
|
||||
variant="solid"
|
||||
className={buttonStyles({ intent: "theme" })}
|
||||
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
|
||||
onPress={activeTab === 'deposit' ? handleDeposit : handleWithdraw}
|
||||
>
|
||||
{!mounted && t("common.connectWallet")}
|
||||
{mounted && !isConnected && t("common.connectWallet")}
|
||||
{mounted && isConnected && currentStatus === 'idle' && amountInvalid && t("common.invalidAmount")}
|
||||
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'deposit' && t("alp.addLiquidity")}
|
||||
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'withdraw' && withdrawCooldownLeft > 0 && `15-min Withdrawal Lock (${String(Math.floor(withdrawCooldownLeft / 60)).padStart(2, '0')}:${String(withdrawCooldownLeft % 60).padStart(2, '0')})`}
|
||||
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'withdraw' && withdrawCooldownLeft === 0 && t("alp.removeLiquidity")}
|
||||
{mounted && currentStatus === 'approving' && t("common.approving")}
|
||||
{mounted && currentStatus === 'approved' && (activeTab === 'deposit' ? t("alp.approvedAdding") : t("alp.approvedRemoving"))}
|
||||
{mounted && (currentStatus === 'depositing' || currentStatus === 'withdrawing') && (activeTab === 'deposit' ? t("alp.adding") : t("alp.removing"))}
|
||||
{mounted && currentStatus === 'success' && t("common.success")}
|
||||
{mounted && currentStatus === 'error' && t("common.failed")}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Info Note */}
|
||||
<div className="text-center">
|
||||
<span className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">
|
||||
{activeTab === 'deposit' ? t("alp.depositNote") : t("alp.withdrawNote")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
webapp/components/alp/PriceHistoryCard.tsx
Normal file
230
webapp/components/alp/PriceHistoryCard.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import * as echarts from "echarts";
|
||||
|
||||
interface HistoryPoint {
|
||||
time: string;
|
||||
ts: number;
|
||||
poolAPR: number;
|
||||
alpPrice: number;
|
||||
feeSurplus: number;
|
||||
poolValue: number;
|
||||
}
|
||||
|
||||
export default function PriceHistoryCard() {
|
||||
const { t } = useApp();
|
||||
const [activeTab, setActiveTab] = useState<"price" | "apr">("price");
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
|
||||
const { data: history = [], isLoading } = useQuery<HistoryPoint[]>({
|
||||
queryKey: ["alp-history"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/alp/history?days=30");
|
||||
const json = await res.json();
|
||||
return json.data ?? [];
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const isEmpty = history.length < 2;
|
||||
|
||||
const labels = isEmpty ? [] : history.map(p => p.time);
|
||||
const priceData = isEmpty ? [] : history.map(p => p.alpPrice);
|
||||
const aprData = isEmpty ? [] : history.map(p => p.poolAPR);
|
||||
|
||||
const activeData = activeTab === "price" ? priceData : aprData;
|
||||
const activeColor = activeTab === "price" ? "#10b981" : "#1447e6";
|
||||
const areaColor0 = activeTab === "price" ? "rgba(16,185,129,0.3)" : "rgba(20,71,230,0.25)";
|
||||
const areaColor1 = activeTab === "price" ? "rgba(16,185,129,0)" : "rgba(20,71,230,0)";
|
||||
const suffix = activeTab === "price" ? " USDC" : "%";
|
||||
const precision = activeTab === "price" ? 4 : 4;
|
||||
|
||||
const highest = activeData.length > 0 ? Math.max(...activeData) : 0;
|
||||
const lowest = activeData.length > 0 ? Math.min(...activeData) : 0;
|
||||
const current = activeData.length > 0 ? activeData[activeData.length - 1] : 0;
|
||||
|
||||
// Avg APR from feeSurplus / poolValue delta
|
||||
const actualDays = history.length >= 2
|
||||
? (history[history.length - 1].ts - history[0].ts) / 86400
|
||||
: 0;
|
||||
const avgAPR = (() => {
|
||||
if (history.length < 2 || actualDays <= 0) return 0;
|
||||
const first = history[0];
|
||||
const last = history[history.length - 1];
|
||||
const surplus = (last.feeSurplus ?? 0) - (first.feeSurplus ?? 0);
|
||||
const pv = last.poolValue ?? 0;
|
||||
if (pv <= 0 || surplus <= 0) return 0;
|
||||
return surplus / pv / actualDays * 365 * 100;
|
||||
})();
|
||||
|
||||
const periodLabel = actualDays >= 1
|
||||
? `Last ${Math.round(actualDays)} days`
|
||||
: actualDays > 0
|
||||
? `Last ${Math.round(actualDays * 24)}h`
|
||||
: "Last 30 days";
|
||||
|
||||
const fmt = (v: number) =>
|
||||
activeTab === "price" ? `$${v.toFixed(precision)}` : `${v.toFixed(precision)}%`;
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance.current) return;
|
||||
|
||||
// 自适应 Y 轴:以数据范围为基础,上下各留 20% 的 padding
|
||||
const yMin = activeData.length > 0 ? Math.min(...activeData) : 0;
|
||||
const yMax = activeData.length > 0 ? Math.max(...activeData) : 1;
|
||||
const range = yMax - yMin || yMax * 0.01 || 0.01;
|
||||
const yAxisMin = yMin - range * 0.2;
|
||||
const yAxisMax = yMax + range * 0.2;
|
||||
|
||||
chartInstance.current.setOption({
|
||||
grid: { left: 0, right: 0, top: 10, bottom: 24 },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
confine: true,
|
||||
backgroundColor: "rgba(17,24,39,0.9)",
|
||||
borderColor: "#374151",
|
||||
textStyle: { color: "#f9fafb", fontSize: 12 },
|
||||
formatter: (params: any) => {
|
||||
const d = params[0];
|
||||
const val = Number(d.value).toFixed(precision);
|
||||
const display = activeTab === "price" ? `$${val}` : `${val}%`;
|
||||
return `<div style="padding:4px 8px"><span style="color:#9ca3af;font-size:11px">${d.name}</span><br/><span style="color:${activeColor};font-weight:600;font-size:14px">${display}</span></div>`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: labels,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: "#9ca3af",
|
||||
fontSize: 11,
|
||||
interval: Math.max(0, Math.floor(labels.length / 6) - 1),
|
||||
},
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
min: yAxisMin,
|
||||
max: yAxisMax,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
series: [{
|
||||
data: activeData,
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: activeColor, width: 2 },
|
||||
itemStyle: { color: activeColor },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: areaColor0 },
|
||||
{ offset: 1, color: areaColor1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
}],
|
||||
}, true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!chartRef.current) return;
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
updateChart();
|
||||
chartInstance.current?.resize();
|
||||
});
|
||||
const handleResize = () => chartInstance.current?.resize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => { cancelAnimationFrame(frame); window.removeEventListener("resize", handleResize); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, history]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { chartInstance.current?.dispose(); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 px-6 py-6 flex flex-col gap-0 h-full">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-6 border-b border-border-gray dark:border-gray-700 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("price")}
|
||||
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
|
||||
activeTab === "price"
|
||||
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
|
||||
: "text-text-tertiary dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{t("alp.priceHistory")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("apr")}
|
||||
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
|
||||
activeTab === "apr"
|
||||
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
|
||||
: "text-text-tertiary dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{t("alp.aprHistory")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Period label + avg for APR */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
|
||||
{periodLabel}
|
||||
</div>
|
||||
{activeTab === "apr" && (
|
||||
<div className="text-caption-tiny font-bold text-[#1447e6]">
|
||||
Avg APR: {avgAPR.toFixed(4)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="relative w-full border-b border-border-gray dark:border-gray-600" style={{ height: "260px" }}>
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm text-text-tertiary dark:text-gray-400">{t("common.loading")}</span>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm text-text-tertiary dark:text-gray-500">
|
||||
{t("common.noData")}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={chartRef} className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{[
|
||||
{ label: t("alp.highest"), value: isEmpty ? "--" : fmt(highest) },
|
||||
{ label: t("alp.lowest"), value: isEmpty ? "--" : fmt(lowest) },
|
||||
{ label: t("alp.current"), value: isEmpty ? "--" : fmt(current) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex items-start justify-between">
|
||||
<div className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">{label}</div>
|
||||
<div className="text-caption-tiny font-bold text-text-primary dark:text-white tabular-nums">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user