init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
63
webapp/components/common/BorderedButton.tsx
Normal file
63
webapp/components/common/BorderedButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
429
webapp/components/common/ReviewModal.tsx
Normal file
429
webapp/components/common/ReviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
webapp/components/common/Toast.tsx
Normal file
61
webapp/components/common/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
webapp/components/common/TokenSelector.tsx
Normal file
137
webapp/components/common/TokenSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
566
webapp/components/common/WithdrawModal.tsx
Normal file
566
webapp/components/common/WithdrawModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user