包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
501 lines
22 KiB
TypeScript
501 lines
22 KiB
TypeScript
"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>
|
||
);
|
||
}
|