Files
assetx/webapp/components/common/TradePanel.tsx

501 lines
22 KiB
TypeScript
Raw Permalink Normal View History

"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 { useAccount, useReadContract, useGasPrice } from 'wagmi';
import { formatUnits, parseUnits } from 'viem';
import { useTokenBalance } from '@/hooks/useBalance';
import { useSwap } from '@/hooks/useSwap';
import { abis, getContractAddress } from '@/lib/contracts';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import Toast from "@/components/common/Toast";
import TokenSelector from '@/components/common/TokenSelector';
import { Token } from '@/lib/api/tokens';
interface TradePanelProps {
showHeader?: boolean;
title?: string;
subtitle?: string;
}
export default function TradePanel({
showHeader = false,
title = "",
subtitle = "",
}: TradePanelProps) {
const { t } = useApp();
const [sellAmount, setSellAmount] = useState<string>("");
const [buyAmount, setBuyAmount] = useState<string>("");
const [mounted, setMounted] = useState(false);
const [tokenIn, setTokenIn] = useState<string>('');
const [tokenInObj, setTokenInObj] = useState<Token | undefined>();
const [tokenOut, setTokenOut] = useState<string>('');
const [tokenOutObj, setTokenOutObj] = useState<Token | undefined>();
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" | "warning" } | null>(null);
const [slippage, setSlippage] = useState<number>(0.5);
const SLIPPAGE_OPTIONS = [0.3, 0.5, 1, 3];
useEffect(() => {
setMounted(true);
}, []);
const { address, isConnected, chainId } = useAccount();
// 从合约地址读取精度tokenIn / tokenOut
const sellInputDecimals = useTokenDecimalsFromAddress(
tokenInObj?.contractAddress,
tokenInObj?.decimals ?? 18
);
const buyInputDecimals = useTokenDecimalsFromAddress(
tokenOutObj?.contractAddress,
tokenOutObj?.decimals ?? 18
);
const sellDisplayDecimals = Math.min(sellInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidSellAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleSellAmountChange = (value: string) => {
if (value === '') { setSellAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > sellDisplayDecimals) return;
const maxBalance = truncateDecimals(tokenInBalance, sellDisplayDecimals);
if (parseFloat(value) > parseFloat(maxBalance)) { setSellAmount(maxBalance); return; }
setSellAmount(value);
};
// 余额:通过合约地址动态查询
const { formattedBalance: tokenInBalance, isLoading: isBalanceLoading, refetch: refetchTokenIn } = useTokenBalance(
tokenInObj?.contractAddress,
sellInputDecimals
);
const { formattedBalance: tokenOutBalance, refetch: refetchTokenOut } = useTokenBalance(
tokenOutObj?.contractAddress,
buyInputDecimals
);
// 查询池子流动性YTVault.usdyAmounts(tokenOutAddress)
const vaultAddress = chainId ? getContractAddress('YTVault', chainId) : undefined;
const tokenInAddress = tokenInObj?.contractAddress;
const tokenOutAddress = tokenOutObj?.contractAddress;
const { data: poolLiquidityRaw, refetch: refetchPool } = useReadContract({
address: vaultAddress,
abi: abis.YTVault,
functionName: 'usdyAmounts',
args: tokenOutAddress ? [tokenOutAddress as `0x${string}`] : undefined,
query: {
enabled: !!vaultAddress && !!tokenOutAddress,
}
});
const poolLiquidityOut = poolLiquidityRaw ? formatUnits(poolLiquidityRaw as bigint, buyInputDecimals) : '0';
// 查询实际兑换数量getSwapAmountOut(tokenIn, tokenOut, amountIn)
const sellAmountWei = (() => {
if (!sellAmount || !isValidSellAmount(sellAmount)) return undefined;
try { return parseUnits(sellAmount, sellInputDecimals); } catch { return undefined; }
})();
const { data: swapAmountOutRaw } = useReadContract({
address: vaultAddress,
abi: abis.YTVault,
functionName: 'getSwapAmountOut',
args: tokenInAddress && tokenOutAddress && sellAmountWei !== undefined
? [tokenInAddress as `0x${string}`, tokenOutAddress as `0x${string}`, sellAmountWei]
: undefined,
query: {
enabled: !!vaultAddress && !!tokenInAddress && !!tokenOutAddress && sellAmountWei !== undefined && tokenIn !== tokenOut,
},
});
// 返回 [amountOut, amountOutAfterFees, feeBasisPoints]
const swapRaw = swapAmountOutRaw as [bigint, bigint, bigint] | undefined;
const swapAmountOut = swapRaw ? formatUnits(swapRaw[1], buyInputDecimals) : '';
const swapFeeBps = swapRaw ? Number(swapRaw[2]) : 0;
// 读取 vault 基础 swap fee (bps)
const { data: baseFeeRaw } = useReadContract({
address: vaultAddress,
abi: abis.YTVault,
functionName: 'swapFeeBasisPoints',
query: { enabled: !!vaultAddress },
});
const baseBps = baseFeeRaw ? Number(baseFeeRaw as bigint) : 0;
// Gas price for network cost estimate
const { data: gasPriceWei } = useGasPrice({ chainId });
const GAS_ESTIMATE = 500_000n;
const networkCostWei = gasPriceWei ? GAS_ESTIMATE * gasPriceWei : 0n;
const networkCostNative = networkCostWei > 0n ? parseFloat(formatUnits(networkCostWei, 18)) : 0;
const nativeToken = chainId === 97 ? 'BNB' : 'ETH';
// Fee & Price Impact 计算
const feeAmountBigInt = swapRaw ? swapRaw[0] - swapRaw[1] : 0n;
const feeAmountFormatted = feeAmountBigInt > 0n ? truncateDecimals(formatUnits(feeAmountBigInt, buyInputDecimals), 6) : '0';
const feePercent = swapFeeBps / 100;
const impactBps = Math.max(0, swapFeeBps - baseBps);
const impactPercent = impactBps / 100;
const {
status: swapStatus,
error: swapError,
isLoading: isSwapLoading,
executeApproveAndSwap,
reset: resetSwap,
} = useSwap();
// 买入数量由合约 getSwapAmountOut 决定
useEffect(() => {
if (swapAmountOut && isValidSellAmount(sellAmount)) {
setBuyAmount(truncateDecimals(swapAmountOut, 6));
} else {
setBuyAmount("");
}
}, [swapAmountOut, sellAmount]);
// 交易成功后刷新余额
useEffect(() => {
if (swapStatus === 'success') {
setToast({ message: `${t('swap.successMsg')} ${tokenIn} ${t('swap.to')} ${tokenOut}!`, type: "success" });
refetchTokenIn();
refetchTokenOut();
refetchPool();
setTimeout(() => {
resetSwap();
setSellAmount("");
setBuyAmount("");
}, 3000);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapStatus]);
// 显示错误提示
useEffect(() => {
if (swapError) {
setToast({ message: swapError, type: swapError === 'Transaction cancelled' ? "warning" : "error" });
refetchTokenIn();
refetchTokenOut();
refetchPool();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapError]);
// 处理 tokenIn 选择
const handleTokenInSelect = useCallback((token: Token) => {
if (tokenInObj && token.symbol === tokenIn) return;
if (token.symbol === tokenOut) {
setTokenOut(tokenIn);
setTokenOutObj(tokenInObj);
}
setTokenInObj(token);
setTokenIn(token.symbol);
setSellAmount("");
setBuyAmount("");
}, [tokenIn, tokenOut, tokenInObj]);
// 处理 tokenOut 选择
const handleTokenOutSelect = useCallback((token: Token) => {
if (tokenOutObj && token.symbol === tokenOut) return;
if (token.symbol === tokenIn) {
setTokenIn(tokenOut);
setTokenInObj(tokenOutObj);
}
setTokenOutObj(token);
setTokenOut(token.symbol);
setSellAmount("");
setBuyAmount("");
}, [tokenIn, tokenOut, tokenOutObj]);
// 切换交易方向
const handleSwapDirection = () => {
const tempSymbol = tokenIn;
const tempObj = tokenInObj;
setTokenIn(tokenOut);
setTokenInObj(tokenOutObj);
setTokenOut(tempSymbol);
setTokenOutObj(tempObj);
setSellAmount("");
setBuyAmount("");
};
// 百分比按钮处理
const handlePercentage = (percentage: number) => {
const balance = parseFloat(tokenInBalance);
if (balance > 0) {
const raw = (balance * percentage / 100).toString();
setSellAmount(truncateDecimals(raw, sellDisplayDecimals));
}
};
return (
<div className={`flex flex-col gap-6 ${showHeader ? "w-full md:w-full md:max-w-[600px]" : "w-full"}`}>
{/* Header Section - Optional */}
{showHeader && (
<div className="flex flex-col gap-2">
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white text-center">
{title}
</h1>
<p className="text-body-default font-medium text-text-secondary dark:text-gray-400 text-center">
{subtitle}
</p>
</div>
)}
{/* Trade Panel */}
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6">
<div className="flex flex-col gap-4">
{/* SELL and BUY Container with Exchange Icon */}
<div className="flex flex-col gap-2 relative">
{/* SELL Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-4">
{/* Label and Buttons */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-body-small font-bold text-text-secondary dark:text-gray-300">
{t("alp.sell")}
</span>
<div className="flex items-center gap-1.5">
{[25, 50, 75].map(pct => (
<button
key={pct}
onClick={() => handlePercentage(pct)}
className="flex-1 sm:flex-none bg-[#e5e7eb] dark:bg-gray-600 rounded-full px-2 sm:px-3 h-[22px] text-[10px] font-medium text-text-primary dark:text-white border-none hover:bg-[#d1d5db] dark:hover:bg-gray-500 transition-colors"
>
{pct}%
</button>
))}
<button
onClick={() => handlePercentage(100)}
className="flex-1 sm:flex-none bg-[#e5e7eb] dark:bg-gray-600 rounded-full px-2 sm:px-3 h-[22px] text-[10px] font-medium text-text-primary dark:text-white border-none hover:bg-[#d1d5db] dark:hover:bg-gray-500 transition-colors"
>
{t("mintSwap.max")}
</button>
</div>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-2 min-h-[60px] md:h-[70px]">
<div className="flex flex-col items-start justify-between flex-1 h-full">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={sellAmount}
onChange={(e) => handleSellAmountChange(e.target.value)}
className="w-full text-left text-2xl md:text-[32px] font-bold leading-[130%] tracking-[-0.01em] text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-body-small font-medium text-text-tertiary dark:text-gray-400">
{sellAmount ? `$${sellAmount}` : '--'}
</span>
</div>
<div className="flex flex-col items-end justify-between gap-1 h-full">
<TokenSelector
selectedToken={tokenInObj}
onSelect={handleTokenInSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol="YT-A"
/>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
{!mounted ? `0 ${tokenIn}` : (isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(tokenInBalance, sellDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: sellDisplayDecimals })} ${tokenIn}`)}
</span>
</div>
</div>
</div>
{/* Exchange Icon */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10">
<button
onClick={handleSwapDirection}
className="bg-bg-surface dark:bg-gray-700 rounded-full w-10 h-10 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer"
style={{
boxShadow: "0px 1px 2px -1px rgba(0, 0, 0, 0.1), 0px 1px 3px 0px rgba(0, 0, 0, 0.1)",
}}
>
<Image
src="/components/common/icon-swap-diagonal.svg"
alt="Exchange"
width={18}
height={18}
/>
</button>
</div>
{/* BUY Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-4">
{/* Label */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-body-small font-bold text-text-secondary dark:text-gray-300">
{t("alp.buy")}
</span>
<div className="flex items-center gap-1">
<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-400">
{!mounted ? `0 ${tokenOut}` : `${parseFloat(tokenOutBalance).toLocaleString()} ${tokenOut}`}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-1">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-500">
{t('swap.pool')}: {!mounted ? '...' : `${parseFloat(poolLiquidityOut).toLocaleString()} ${tokenOut}`}
</span>
</div>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-2 min-h-[60px] md:h-[70px]">
<div className="flex flex-col items-start justify-between flex-1 h-full">
<input
type="text"
placeholder="0.00"
value={buyAmount}
readOnly
className="w-full text-left text-2xl md:text-[32px] font-bold leading-[130%] tracking-[-0.01em] text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none cursor-default"
/>
<span className="text-body-small font-medium text-text-tertiary dark:text-gray-400">
{buyAmount ? `$${buyAmount}` : '--'}
</span>
</div>
<div className="flex flex-col items-end justify-between gap-1 h-full">
<TokenSelector
selectedToken={tokenOutObj}
onSelect={handleTokenOutSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol="YT-B"
/>
</div>
</div>
</div>
</div>
{/* Slippage Tolerance */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between px-1">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-400">
{t("swap.slippageTolerance")}
</span>
<div className="flex items-center gap-1">
{SLIPPAGE_OPTIONS.map((opt) => (
<button
key={opt}
onClick={() => setSlippage(opt)}
className={`flex-1 sm:flex-none px-2 sm:px-3 h-7 rounded-full text-[12px] font-semibold transition-colors ${
slippage === opt
? "bg-foreground text-background"
: "bg-[#e5e7eb] dark:bg-gray-600 text-text-primary dark:text-white hover:bg-[#d1d5db] dark:hover:bg-gray-500"
}`}
>
{opt}%
</button>
))}
</div>
</div>
{/* Submit Button */}
{(() => {
const inputAmt = parseFloat(sellAmount) || 0;
const balance = parseFloat(tokenInBalance) || 0;
const poolAmt = parseFloat(poolLiquidityOut) || 0;
const insufficientBalance = inputAmt > 0 && inputAmt > balance;
const insufficientPool = inputAmt > 0 && poolAmt > 0 && inputAmt > poolAmt;
const buttonDisabled = !mounted || !isConnected || !isValidSellAmount(sellAmount) || isSwapLoading || tokenIn === tokenOut || insufficientBalance || insufficientPool || !tokenInObj || !tokenOutObj;
const buttonLabel = (() => {
if (!mounted || !isConnected) return t('common.connectWallet');
if (swapStatus === 'idle' && !!sellAmount && !isValidSellAmount(sellAmount)) return t('common.invalidAmount');
if (insufficientBalance) return t('supply.insufficientBalance');
if (insufficientPool) return t('swap.insufficientLiquidity');
if (swapStatus === 'approving') return t('common.approving');
if (swapStatus === 'approved') return t('swap.approved');
if (swapStatus === 'swapping') return t('swap.swapping');
if (swapStatus === 'success') return t('common.success');
if (swapStatus === 'error') return t('common.failed');
return `${t('swap.swapBtn')} ${tokenIn} ${t('swap.to')} ${tokenOut}`;
})();
const minOut = swapAmountOut && isValidSellAmount(sellAmount)
? (parseFloat(swapAmountOut) * (1 - slippage / 100)).toFixed(buyInputDecimals)
: '0';
return (
<Button
isDisabled={buttonDisabled}
color="default"
className={buttonStyles({ intent: "theme" })}
onPress={() => {
if (!insufficientBalance && !insufficientPool && isValidSellAmount(sellAmount) && tokenIn !== tokenOut && tokenInObj && tokenOutObj) {
setToast(null);
resetSwap();
executeApproveAndSwap(
tokenInObj.contractAddress,
sellInputDecimals,
tokenOutObj.contractAddress,
buyInputDecimals,
sellAmount,
minOut
);
}
}}
>
{buttonLabel}
</Button>
);
})()}
{/* Rate & Info Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 px-4 py-3 flex flex-col gap-2">
{/* Exchange Rate */}
<div className="flex items-center justify-between">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-300">
{swapAmountOut && isValidSellAmount(sellAmount)
? `1 ${tokenIn}${truncateDecimals((parseFloat(swapAmountOut) / parseFloat(sellAmount)).toString(), 6)} ${tokenOut}`
: `1 ${tokenIn} = -- ${tokenOut}`}
</span>
</div>
{/* Divider */}
<div className="border-t border-border-gray dark:border-gray-600" />
{/* Network Cost */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">{t('swap.networkCost')}</span>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300">
{networkCostNative > 0 ? `~${networkCostNative.toFixed(6)} ${nativeToken}` : '--'}
</span>
</div>
{/* Max Slippage */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">{t('swap.maxSlippage')}</span>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300">{slippage}%</span>
</div>
{/* Price Impact */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">{t('swap.priceImpact')}</span>
<span className={`text-caption-tiny font-medium ${
isValidSellAmount(sellAmount) && swapAmountOut
? impactPercent > 1 ? 'text-red-500' : impactPercent > 0.3 ? 'text-amber-500' : 'text-text-secondary dark:text-gray-300'
: 'text-text-secondary dark:text-gray-300'
}`}>
{isValidSellAmount(sellAmount) && swapAmountOut ? `${impactPercent.toFixed(2)}%` : '--'}
</span>
</div>
{/* Fee */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">{t('swap.fee')}</span>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300">
{swapFeeBps > 0 && isValidSellAmount(sellAmount) && swapAmountOut
? `${feePercent.toFixed(2)}% (≈${feeAmountFormatted} ${tokenOut})`
: '--'}
</span>
</div>
</div>
</div>
</div>
{/* Toast Notification */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
isOpen={!!toast}
onClose={() => setToast(null)}
/>
)}
</div>
);
}