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

501 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import { 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>
);
}