init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
500
webapp/components/common/TradePanel.tsx
Normal file
500
webapp/components/common/TradePanel.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user