init: 初始化 AssetX 项目仓库

包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
This commit is contained in:
2026-03-27 11:26:43 +00:00
commit 2ee4553b71
634 changed files with 988255 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
"use client";
import { Button, ButtonProps } from "@heroui/react";
import { tv } from "tailwind-variants";
const borderedButtonStyles = tv({
slots: {
base: "rounded-xl border border-[#E5E7EB] dark:border-gray-600 flex items-center justify-center",
button: "w-full h-full rounded-xl px-6 text-body-small font-bold border-none",
},
variants: {
size: {
md: {
base: "h-10",
},
lg: {
base: "h-11",
},
},
fullWidth: {
true: {
base: "w-full",
},
},
isTheme: {
true: {
button: "bg-white dark:bg-gray-800 text-text-primary dark:text-white",
},
},
},
defaultVariants: {
size: "md",
isTheme: false,
},
});
interface BorderedButtonProps extends ButtonProps {
size?: "md" | "lg";
fullWidth?: boolean;
isTheme?: boolean;
}
export default function BorderedButton({
size = "md",
fullWidth = false,
isTheme = false,
className,
...props
}: BorderedButtonProps) {
const { base, button } = borderedButtonStyles({ size, fullWidth, isTheme });
return (
<div className={base({ className })}>
<Button
className={button()}
{...props}
>
{props.children}
</Button>
</div>
);
}

View File

@@ -0,0 +1,429 @@
"use client";
import Image from "next/image";
import { Modal, ModalContent, Button } from "@heroui/react";
import { buttonStyles } from "@/lib/buttonStyles";
import { USDC_TO_GYUS_RATE, USDC_USD_RATE } from "@/lib/constants";
interface ReviewModalProps {
isOpen: boolean;
onClose: () => void;
amount: string;
productName?: string;
}
export default function ReviewModal({
isOpen,
onClose,
amount,
productName = "High-Yield US Equity",
}: ReviewModalProps) {
const gyusAmount = amount ? (parseFloat(amount) * USDC_TO_GYUS_RATE).toFixed(0) : "0";
const usdValue = amount ? (parseFloat(amount) * USDC_USD_RATE).toFixed(2) : "0.00";
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="lg"
classNames={{
base: "bg-transparent shadow-none",
backdrop: "bg-black/50",
}}
>
<ModalContent>
<div
style={{
background: "#ffffff",
borderRadius: "24px",
padding: "24px",
display: "flex",
flexDirection: "column",
gap: "24px",
position: "relative",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "446px",
}}
>
<div
style={{
color: "#111827",
fontSize: "20px",
fontWeight: 700,
lineHeight: "140%",
fontFamily: "var(--font-inter)",
}}
>
Review
</div>
<button
onClick={onClose}
style={{
width: "24px",
height: "24px",
cursor: "pointer",
background: "none",
border: "none",
padding: 0,
}}
>
<Image
src="/icons/ui/vuesax-linear-close-circle1.svg"
alt="Close"
width={24}
height={24}
/>
</button>
</div>
{/* Product Info */}
<div
style={{
background: "#f9fafb",
borderRadius: "12px",
padding: "16px",
display: "flex",
flexDirection: "row",
gap: "12px",
alignItems: "center",
}}
>
<div
style={{
background: "#fff7ed",
borderRadius: "6px",
border: "0.75px solid rgba(0, 0, 0, 0.1)",
width: "40px",
height: "30px",
boxShadow: "inset 0px 1.5px 3px 0px rgba(0, 0, 0, 0.05)",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Image
src="/assets/flags/lr0.svg"
alt="Product"
width={43}
height={30}
style={{ width: "107.69%", height: "100%", objectFit: "cover" }}
/>
</div>
<div
style={{
color: "#111827",
fontSize: "18px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{productName}
</div>
</div>
{/* Amount Section */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "16px",
width: "446px",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
alignItems: "center",
}}
>
<div
style={{
color: "#4b5563",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Deposit
</div>
<div style={{ textAlign: "center" }}>
<span
style={{
color: "#111827",
fontSize: "48px",
fontWeight: 700,
lineHeight: "120%",
letterSpacing: "-0.01em",
fontFamily: "var(--font-inter)",
}}
>
{amount || "0"}
</span>
<span
style={{
color: "#9ca1af",
fontSize: "20px",
fontWeight: 700,
lineHeight: "140%",
fontFamily: "var(--font-inter)",
marginLeft: "8px",
}}
>
USDC
</span>
</div>
<div
style={{
color: "#4b5563",
fontSize: "18px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
${usdValue}
</div>
</div>
</div>
{/* Transaction Details */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "16px",
width: "446px",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0px",
}}
>
{/* Deposit Row */}
<div
style={{
borderRadius: "16px",
padding: "16px",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "12px",
alignItems: "center",
}}
>
<Image src="/components/common/icon0.svg" alt="" width={20} height={20} />
<div
style={{
color: "#4b5563",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Deposit (USDC)
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
}}
>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{amount ? parseFloat(amount).toLocaleString() : "0"}
</div>
<Image src="/components/common/icon3.svg" alt="" width={14} height={14} />
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
0
</div>
</div>
</div>
{/* Get GYUS Row */}
<div
style={{
borderRadius: "16px",
padding: "16px",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
height: "54px",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "12px",
alignItems: "center",
}}
>
<Image src="/components/common/icon2.svg" alt="" width={20} height={20} />
<div
style={{
color: "#4b5563",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
GET(GYUS)
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
0.00
</div>
<Image src="/components/common/icon3.svg" alt="" width={14} height={14} />
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{gyusAmount}
</div>
</div>
</div>
</div>
{/* APY Info */}
<div
style={{
background: "#f2fcf7",
borderRadius: "16px",
border: "1px solid #cef3e0",
padding: "16px",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
}}
>
<Image src="/components/common/icon4.svg" alt="" width={20} height={20} />
<div
style={{
color: "#10b981",
fontSize: "14px",
fontWeight: 600,
lineHeight: "20px",
letterSpacing: "-0.15px",
fontFamily: "var(--font-inter)",
}}
>
APY
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "4px",
alignItems: "center",
}}
>
<div
style={{
color: "#10b981",
fontSize: "18px",
fontWeight: 700,
lineHeight: "28px",
letterSpacing: "-0.44px",
fontFamily: "var(--font-inter)",
}}
>
22.0%
</div>
<Image src="/components/common/icon5.svg" alt="" width={16} height={16} />
</div>
</div>
</div>
{/* Confirm Button */}
<Button
color="default"
variant="solid"
className={buttonStyles({ intent: "theme" })}
onPress={() => {
onClose();
}}
>
Confirm Transaction
</Button>
</div>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect } from "react";
type ToastType = "success" | "error" | "info" | "warning";
interface ToastProps {
message: string;
type: ToastType;
isOpen: boolean;
onClose: () => void;
duration?: number;
}
export default function Toast({
message,
type,
isOpen,
onClose,
duration = 3000,
}: ToastProps) {
useEffect(() => {
if (isOpen && duration > 0) {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [isOpen, duration, onClose]);
if (!isOpen) return null;
const colors = {
success: "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700 text-green-800 dark:text-green-200",
error: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700 text-red-800 dark:text-red-200",
info: "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700 text-blue-800 dark:text-blue-200",
warning: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700 text-yellow-800 dark:text-yellow-200",
};
const icons = {
success: "✓",
error: "✕",
info: "",
warning: "⚠",
};
return (
<div className="fixed top-4 right-4 z-50 animate-in slide-in-from-top-2 fade-in duration-300">
<div className={`${colors[type]} border rounded-xl px-4 py-3 shadow-lg flex items-center gap-3 min-w-[300px] max-w-[400px]`}>
<span className="text-lg">{icons[type]}</span>
<span className="text-sm font-medium flex-1">{message}</span>
<button
onClick={onClose}
className="text-current opacity-50 hover:opacity-100 transition-opacity"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { useEffect, useRef } from "react";
import Image from "next/image";
import {
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
} from "@heroui/react";
import { useTokenList } from "@/hooks/useTokenList";
import { Token } from "@/lib/api/tokens";
interface TokenSelectorProps {
selectedToken?: Token;
onSelect: (token: Token) => void;
filterTypes?: ('stablecoin' | 'yield-token')[];
disabled?: boolean;
defaultSymbol?: string;
}
export default function TokenSelector({
selectedToken,
onSelect,
filterTypes = ['stablecoin', 'yield-token'],
disabled = false,
defaultSymbol,
}: TokenSelectorProps) {
const hasInitialized = useRef(false);
const { tokens: allTokens, isLoading } = useTokenList();
const tokens = allTokens.filter(t => filterTypes.includes(t.tokenType));
// 数据加载完成后自动选中默认 token仅初始化一次
useEffect(() => {
if (hasInitialized.current || selectedToken || tokens.length === 0) return;
hasInitialized.current = true;
const target = defaultSymbol
? (tokens.find(t => t.symbol === defaultSymbol) ?? tokens[0])
: tokens[0];
onSelect(target);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tokens]);
if (isLoading) {
return (
<button className="bg-white dark:bg-gray-700 rounded-full border border-gray-400 dark:border-gray-500 px-4 h-[46px] flex items-center gap-2 opacity-50 cursor-not-allowed">
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-600 animate-pulse" />
<span className="text-body-default font-bold text-text-primary dark:text-white">
Loading...
</span>
</button>
);
}
return (
<Dropdown shouldBlockScroll={false}>
<DropdownTrigger>
<button
disabled={disabled}
className={`bg-white dark:bg-gray-700 rounded-full border border-gray-400 dark:border-gray-500 px-4 h-[46px] flex items-center justify-between gap-2 outline-none focus:outline-none transition-all duration-200 ease-in-out ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary dark:hover:border-primary-dark hover:shadow-md'
}`}
>
{selectedToken ? (
<>
<div className="flex items-center gap-2">
<Image
src={selectedToken.iconUrl || '/assets/tokens/default.svg'}
alt={selectedToken.symbol}
width={32}
height={32}
className="rounded-full transition-opacity duration-150"
priority
onError={(e) => {
(e.target as HTMLImageElement).src = '/assets/tokens/default.svg';
}}
/>
<span className="text-body-default font-bold text-text-primary dark:text-white transition-opacity duration-150">
{selectedToken.symbol}
</span>
</div>
<svg
className="w-4 h-4 text-text-tertiary dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</>
) : (
<span className="text-body-default text-text-tertiary dark:text-gray-400">
No tokens available
</span>
)}
</button>
</DropdownTrigger>
<DropdownMenu
aria-label="Token selection"
onAction={(key) => {
const token = tokens.find(t => t.symbol === key);
if (token) onSelect(token);
}}
>
{tokens.map((token) => (
<DropdownItem
key={token.symbol}
className="hover:bg-bg-subtle dark:hover:bg-gray-700 rounded-lg"
textValue={token.symbol}
>
<div className="flex items-center gap-3 py-1">
<Image
src={token.iconUrl || '/assets/tokens/default.svg'}
alt={token.symbol || 'token'}
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-default font-bold text-text-primary dark:text-white">
{token.symbol}
</span>
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">
{token.name}
</span>
</div>
</div>
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
);
}

View 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>
);
}

View File

@@ -0,0 +1,566 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Modal, ModalContent, Button } from "@heroui/react";
import { buttonStyles } from "@/lib/buttonStyles";
import { USDC_TO_ALP_RATE, USDC_USD_RATE, WITHDRAW_FEE_RATE, SLIPPAGE_PROTECTION } from "@/lib/constants";
interface WithdrawModalProps {
isOpen: boolean;
onClose: () => void;
amount: string;
}
export default function WithdrawModal({
isOpen,
onClose,
amount,
}: WithdrawModalProps) {
const [showDetails, setShowDetails] = useState(true);
const alpAmount = amount ? (parseFloat(amount) * USDC_TO_ALP_RATE).toFixed(0) : "0";
const usdValue = amount ? (parseFloat(amount) * USDC_USD_RATE).toFixed(2) : "0.00";
const fee = amount ? (parseFloat(amount) * WITHDRAW_FEE_RATE).toFixed(2) : "0.00";
const minReceive = amount ? (parseFloat(amount) * USDC_TO_ALP_RATE * SLIPPAGE_PROTECTION).toFixed(0) : "0";
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="lg"
classNames={{
base: "bg-transparent shadow-none",
backdrop: "bg-black/50",
}}
>
<ModalContent>
<div
style={{
background: "#ffffff",
borderRadius: "24px",
padding: "24px",
display: "flex",
flexDirection: "column",
gap: "24px",
position: "relative",
overflow: "hidden",
alignItems: "center",
}}
>
{/* Header */}
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "446px",
}}
>
<div
style={{
color: "#111827",
fontSize: "20px",
fontWeight: 700,
lineHeight: "140%",
fontFamily: "var(--font-inter)",
}}
>
Review
</div>
<button
onClick={onClose}
style={{
width: "24px",
height: "24px",
cursor: "pointer",
background: "none",
border: "none",
padding: 0,
}}
>
<Image
src="/icons/ui/vuesax-linear-close-circle1.svg"
alt="Close"
width={24}
height={24}
/>
</button>
</div>
{/* Exchange Section */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
alignItems: "center",
}}
>
<div
style={{
background: "#f9fafb",
borderRadius: "16px",
padding: "24px 16px",
display: "flex",
flexDirection: "column",
gap: "0px",
alignItems: "center",
width: "446px",
}}
>
{/* From USDC */}
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
alignItems: "flex-start",
}}
>
<div
style={{
color: "#111827",
fontSize: "24px",
fontWeight: 700,
lineHeight: "130%",
letterSpacing: "-0.005em",
fontFamily: "var(--font-inter)",
}}
>
{amount || "0"} USDC
</div>
<div
style={{
color: "#4b5563",
fontSize: "16px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
${usdValue}
</div>
</div>
<Image
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
alt="USDC"
width={36}
height={36}
/>
</div>
{/* Exchange Icon */}
<div
style={{
background: "#ffffff",
borderRadius: "999px",
width: "40px",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
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/group-9280.svg"
alt="Exchange"
width={8}
height={16}
style={{ objectFit: "contain" }}
/>
</div>
{/* To ALP */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0px",
alignItems: "flex-start",
width: "100%",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
}}
>
<div
style={{
color: "#111827",
fontSize: "24px",
fontWeight: 700,
lineHeight: "130%",
letterSpacing: "-0.005em",
fontFamily: "var(--font-inter)",
}}
>
{alpAmount} ALP
</div>
<div
style={{
width: "36px",
height: "36px",
position: "relative",
}}
>
<Image
src="/components/common/vector0.svg"
alt=""
width={36}
height={36}
style={{ position: "absolute" }}
/>
<Image
src="/components/common/vector1.svg"
alt=""
width={36}
height={36}
style={{ position: "absolute" }}
/>
<Image
src="/components/common/vector2.svg"
alt=""
width={36}
height={36}
style={{ position: "absolute" }}
/>
<Image
src="/components/common/component-10.svg"
alt=""
width={36}
height={36}
style={{ position: "absolute" }}
/>
</div>
</div>
<div
style={{
color: "#4b5563",
fontSize: "16px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
${usdValue}
</div>
</div>
</div>
{/* Show More/Less Button */}
<button
onClick={() => setShowDetails(!showDetails)}
style={{
background: "#ffffff",
borderRadius: "999px",
padding: "0px 16px",
border: "none",
cursor: "pointer",
display: "flex",
flexDirection: "row",
gap: "0px",
alignItems: "center",
justifyContent: "center",
width: "120px",
height: "32px",
boxShadow:
"0px 1px 2px -1px rgba(0, 0, 0, 0.1), 0px 1px 3px 0px rgba(0, 0, 0, 0.1)",
}}
>
<div
style={{
color: "#4b5563",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{showDetails ? "Show less" : "Show more"}
</div>
<Image
src="/components/common/icon1.svg"
alt=""
width={12}
height={12}
style={{
transform: showDetails ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease-in-out",
}}
/>
</button>
{/* Transaction Details */}
{showDetails && (
<div
style={{
background: "#f9fafb",
borderRadius: "16px",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "8px",
width: "446px",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Fee(0.5%)
</div>
<div
style={{
color: "#ff6900",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
-${fee}
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Network cost
</div>
<div
style={{
color: "#ff6900",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
-$0.09
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Rate
</div>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
1USDC=0.98ALP ($1.02)
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Spread
</div>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
0
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Price Impact
</div>
<div
style={{
color: "#10b981",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
+$0.60 (+0.065%)
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Max slippage
</div>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
0.5%
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Min Receive
</div>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{minReceive} ALP
</div>
</div>
</div>
)}
</div>
{/* Confirm Button */}
<Button
color="default"
variant="solid"
className={buttonStyles({ intent: "theme" })}
onPress={() => {
onClose();
}}
>
Confirm
</Button>
</div>
</ModalContent>
</Modal>
);
}