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

504 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import { useState, useEffect, useMemo } from "react";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
import { Tabs, Tab, Button } from "@heroui/react";
import ReviewModal from "@/components/common/ReviewModal";
import WithdrawModal from "@/components/common/WithdrawModal";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount, useReadContract } from 'wagmi';
import { useQuery } from '@tanstack/react-query';
import { fetchContracts } from '@/lib/api/contracts';
import { abis } from '@/lib/contracts';
import { useUSDCBalance, useTokenBalance } from '@/hooks/useBalance';
import { useDeposit } from '@/hooks/useDeposit';
import { useWithdraw } from '@/hooks/useWithdraw';
import { getTxUrl } from '@/lib/contracts';
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
import { toast } from "sonner";
import TokenSelector from '@/components/common/TokenSelector';
import { Token } from '@/lib/api/tokens';
import { useAppKit } from "@reown/appkit/react";
interface MintSwapPanelProps {
tokenType?: string;
decimals?: number;
onVaultRefresh?: () => void;
}
export default function MintSwapPanel({ tokenType = 'YT-A', decimals, onVaultRefresh }: MintSwapPanelProps) {
const { t } = useApp();
const [activeAction, setActiveAction] = useState<"deposit" | "withdraw">("deposit");
const [amount, setAmount] = useState<string>("");
const [selectedToken, setSelectedToken] = useState<Token | undefined>();
const [isReviewModalOpen, setIsReviewModalOpen] = useState(false);
const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false);
const [mounted, setMounted] = useState(false);
// 避免 hydration 错误
useEffect(() => {
setMounted(true);
}, []);
// Web3 集成
const { isConnected } = useAccount();
const { open } = useAppKit();
const { formattedBalance: usdcBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useUSDCBalance();
const ytToken = useTokenBySymbol(tokenType ?? '');
const { formattedBalance: ytBalance, refetch: refetchYT } = useTokenBalance(
ytToken?.contractAddress,
ytToken?.decimals ?? decimals ?? 18
);
const {
status: depositStatus,
error: depositError,
isLoading: isDepositLoading,
approveHash,
depositHash,
executeApproveAndDeposit,
reset: resetDeposit,
} = useDeposit(ytToken);
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawLoading,
approveHash: withdrawApproveHash,
withdrawHash,
executeApproveAndWithdraw,
reset: resetWithdraw,
} = useWithdraw(ytToken);
// 从合约读取 nextRedemptionTime复用 contract-registry 缓存
const { data: contractConfigs = [] } = useQuery({
queryKey: ['contract-registry'],
queryFn: fetchContracts,
staleTime: 5 * 60 * 1000,
});
const vaultChainId = ytToken?.chainId ?? 97;
const factoryAddress = useMemo(() => {
const c = contractConfigs.find(c => c.name === 'YTAssetFactory' && c.chain_id === vaultChainId);
return c?.address as `0x${string}` | undefined;
}, [contractConfigs, vaultChainId]);
const priceFeedAddress = useMemo(() => {
const c = contractConfigs.find(c => c.name === 'YTPriceFeed' && c.chain_id === vaultChainId);
return c?.address as `0x${string}` | undefined;
}, [contractConfigs, vaultChainId]);
// Read selected token price from YTPriceFeed (30 dec precision)
const { data: tokenPriceRaw } = useReadContract({
address: priceFeedAddress,
abi: abis.YTPriceFeed as any,
functionName: 'getPrice',
args: selectedToken?.contractAddress
? [selectedToken.contractAddress as `0x${string}`, false]
: undefined,
chainId: vaultChainId,
query: { enabled: !!priceFeedAddress && !!selectedToken?.contractAddress },
});
const selectedTokenUSDPrice = tokenPriceRaw && (tokenPriceRaw as bigint) > 0n
? Number(tokenPriceRaw as bigint) / 1e30
: null; // null = price not yet loaded
const { data: vaultInfo, isLoading: isVaultInfoLoading } = useReadContract({
address: factoryAddress,
abi: abis.YTAssetFactory as any,
functionName: 'getVaultInfo',
args: ytToken?.contractAddress ? [ytToken.contractAddress as `0x${string}`] : undefined,
chainId: vaultChainId,
query: { enabled: !!factoryAddress && !!ytToken?.contractAddress },
});
// 超时 3 秒后不再等合约数据
const [vaultInfoTimedOut, setVaultInfoTimedOut] = useState(false);
useEffect(() => {
if (!factoryAddress || !ytToken?.contractAddress) return;
setVaultInfoTimedOut(false);
const t = setTimeout(() => setVaultInfoTimedOut(true), 3000);
return () => clearTimeout(t);
}, [factoryAddress, ytToken?.contractAddress]);
// nextRedemptionTime: getVaultInfo 返回值 index[8]
const nextRedemptionTime = vaultInfo ? Number((vaultInfo as any[])[8]) : 0;
const isMatured = nextRedemptionTime > 0 && nextRedemptionTime * 1000 < Date.now();
// isFull: totalSupply[4] >= hardCap[5]
const vaultTotalSupply: bigint = vaultInfo ? ((vaultInfo as any[])[4] as bigint) ?? 0n : 0n;
const vaultHardCap: bigint = vaultInfo ? ((vaultInfo as any[])[5] as bigint) ?? 0n : 0n;
const isFull = vaultHardCap > 0n && vaultTotalSupply >= vaultHardCap;
const maturityDateStr = nextRedemptionTime > 0
? new Date(nextRedemptionTime * 1000).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
: '';
// ytPrice: getVaultInfo 返回值 index[7]30 位小数
const ytPriceRaw: bigint = vaultInfo ? ((vaultInfo as any[])[7] as bigint) ?? 0n : 0n;
const ytPriceDisplay = (() => {
if (!ytPriceRaw || ytPriceRaw <= 0n) return '--';
const divisor = 10n ** 30n;
const intPart = ytPriceRaw / divisor;
const fracScaled = ((ytPriceRaw % divisor) * 1_000_000n) / divisor;
return `$${intPart}.${fracScaled.toString().padStart(6, '0')}`;
})();
const usdcToken = useTokenBySymbol('USDC');
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18;
const ytDecimals = ytToken?.onChainDecimals ?? ytToken?.decimals ?? decimals ?? 18;
const inputDecimals = activeAction === 'deposit' ? usdcDecimals : ytDecimals;
const displayDecimals = Math.min(inputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
// ytPrice as float for You Get calculation (30 dec)
const ytPrice = ytPriceRaw > 0n ? Number(ytPriceRaw) / 1e30 : 0;
// You Get = selectedTokenUSDPrice × amount / ytPrice
const depositYouGet = ytPrice > 0 && selectedTokenUSDPrice !== null && isValidAmount(amount)
? (selectedTokenUSDPrice * parseFloat(amount)) / ytPrice
: null;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > displayDecimals) return;
const maxBalance = activeAction === 'deposit' ? parseFloat(usdcBalance) : parseFloat(ytBalance);
if (parseFloat(value) > maxBalance) {
setAmount(truncateDecimals(activeAction === 'deposit' ? usdcBalance : ytBalance, displayDecimals));
return;
}
setAmount(value);
};
const DEPOSIT_TOAST_ID = 'mint-deposit-tx';
const WITHDRAW_TOAST_ID = 'mint-withdraw-tx';
// Approve 交易提交 toast
useEffect(() => {
if (approveHash) {
toast.loading(t("mintSwap.toast.approvalSubmitted"), {
id: DEPOSIT_TOAST_ID,
description: t("mintSwap.toast.waitingConfirmation"),
action: { label: t("mintSwap.toast.viewTx"), onClick: () => window.open(getTxUrl(approveHash), '_blank') },
});
}
}, [approveHash]);
useEffect(() => {
if (withdrawApproveHash) {
toast.loading(t("mintSwap.toast.approvalSubmitted"), {
id: WITHDRAW_TOAST_ID,
description: t("mintSwap.toast.waitingConfirmation"),
action: { label: t("mintSwap.toast.viewTx"), onClick: () => window.open(getTxUrl(withdrawApproveHash), '_blank') },
});
}
}, [withdrawApproveHash]);
// 主交易提交 toast
useEffect(() => {
if (depositHash && depositStatus === 'depositing') {
toast.loading(t("mintSwap.toast.depositSubmitted"), {
id: DEPOSIT_TOAST_ID,
description: t("mintSwap.toast.waitingConfirmation"),
action: { label: t("mintSwap.toast.viewTx"), onClick: () => window.open(getTxUrl(depositHash), '_blank') },
});
}
}, [depositHash, depositStatus]);
useEffect(() => {
if (withdrawHash && withdrawStatus === 'withdrawing') {
toast.loading(t("mintSwap.toast.withdrawSubmitted"), {
id: WITHDRAW_TOAST_ID,
description: t("mintSwap.toast.waitingConfirmation"),
action: { label: t("mintSwap.toast.viewTx"), onClick: () => window.open(getTxUrl(withdrawHash), '_blank') },
});
}
}, [withdrawHash, withdrawStatus]);
// 存款成功后刷新余额
useEffect(() => {
if (depositStatus === 'success') {
toast.success(t("mintSwap.toast.depositSuccess"), {
id: DEPOSIT_TOAST_ID,
description: t("mintSwap.toast.depositSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchYT();
onVaultRefresh?.();
const timer1 = setTimeout(() => { refetchBalance(); refetchYT(); onVaultRefresh?.(); }, 3000);
const timer2 = setTimeout(() => { resetDeposit(); setAmount(""); }, 6000);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [depositStatus]);
// 取款成功后刷新余额
useEffect(() => {
if (withdrawStatus === 'success') {
toast.success(t("mintSwap.toast.withdrawSuccess"), {
id: WITHDRAW_TOAST_ID,
description: t("mintSwap.toast.withdrawSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchYT();
onVaultRefresh?.();
const timer1 = setTimeout(() => { refetchBalance(); refetchYT(); onVaultRefresh?.(); }, 3000);
const timer2 = setTimeout(() => { resetWithdraw(); setAmount(""); }, 6000);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withdrawStatus]);
// 错误 toast
useEffect(() => {
if (depositError) {
if (depositError === 'Transaction cancelled') {
toast.dismiss(DEPOSIT_TOAST_ID);
} else {
toast.error(t("mintSwap.toast.depositFailed"), { id: DEPOSIT_TOAST_ID, duration: 5000 });
}
}
}, [depositError]);
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.dismiss(WITHDRAW_TOAST_ID);
} else {
toast.error(t("mintSwap.toast.withdrawFailed"), { id: WITHDRAW_TOAST_ID, duration: 5000 });
}
}
}, [withdrawError]);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden">
{/* Content */}
<div className="flex flex-col gap-4 md:gap-6 p-4 md:p-6">
{/* Deposit/Withdraw Toggle */}
<Tabs
selectedKey={activeAction}
onSelectionChange={(key) => {
const next = key as "deposit" | "withdraw";
setActiveAction(next);
setAmount("");
if (next === "deposit") { resetWithdraw(); } else { resetDeposit(); }
}}
variant="solid"
classNames={{
base: "w-full",
tabList: "bg-[#f9fafb] dark:bg-gray-700 rounded-xl p-1 gap-0 w-full",
cursor: "bg-bg-surface dark:bg-gray-600 shadow-sm",
tab: "h-8 px-4",
tabContent: "text-body-small font-medium text-text-tertiary dark:text-gray-400 group-data-[selected=true]:font-bold group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
}}
>
<Tab key="deposit" title={t("mintSwap.deposit")} />
<Tab key="withdraw" title={t("mintSwap.withdraw")} />
</Tabs>
{/* Input Area */}
<div className="flex flex-col gap-2">
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-3">
{/* Label and Balance */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{activeAction === 'deposit' ? t("mintSwap.deposit") : t("mintSwap.withdraw")}
</span>
<div className="flex items-center gap-1">
<Image src="/components/common/icon0.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{t("mintSwap.balance")}: {!mounted ? '0' : (isBalanceLoading ? '...' : activeAction === 'deposit'
? `$${parseFloat(truncateDecimals(usdcBalance, displayDecimals)).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })}`
: `${parseFloat(truncateDecimals(ytBalance, displayDecimals)).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })} ${tokenType}`)}
</span>
</div>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start flex-1">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{amount ? (activeAction === 'deposit' ? `$${amount}` : `$${amount} USDC`) : "--"}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
className={buttonStyles({ intent: "max" })}
onPress={() => setAmount(truncateDecimals(activeAction === 'deposit' ? usdcBalance : ytBalance, displayDecimals))}
>
{t("mintSwap.max")}
</Button>
{activeAction === 'deposit' ? (
<TokenSelector
selectedToken={selectedToken}
onSelect={setSelectedToken}
filterTypes={['stablecoin']}
/>
) : (
<div className="bg-white dark:bg-gray-700 rounded-full px-4 h-[46px] flex items-center gap-2">
<Image
src={ytToken?.iconUrl || '/assets/tokens/default.svg'}
alt={tokenType}
width={32}
height={32}
className="rounded-full"
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
/>
<span className="text-body-default font-bold text-text-primary dark:text-white">{tokenType}</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Your YT Balance */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-2">
<span className="text-body-small font-bold text-[#4b5563] dark:text-gray-300">
{t("mintSwap.yourTokenBalance").replace('{token}', tokenType)}
</span>
<div className="flex items-center justify-between gap-2">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 shrink-0">
{t("mintSwap.currentBalance")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 text-right min-w-0 truncate">
{!mounted ? '0' : `${parseFloat(ytBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })} ${tokenType}`}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 shrink-0">
{t("mintSwap.valueUsdc")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white whitespace-nowrap">
{!mounted ? '$0' : `$${parseFloat(ytBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}`}
</span>
</div>
</div>
{/* Transaction Summary */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-2">
<span className="text-body-small font-bold text-[#4b5563] dark:text-gray-300">
{t("mintSwap.transactionSummary")}
</span>
<div className="flex items-center justify-between gap-2">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 shrink-0">
{t("mintSwap.youGet")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 text-right min-w-0 truncate">
{isValidAmount(amount)
? activeAction === 'deposit'
? depositYouGet !== null
? `${depositYouGet.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${tokenType}`
: (!selectedToken ? '--' : '...')
: `$${parseFloat(amount).toLocaleString('en-US', { maximumFractionDigits: 6 })} USDC`
: '--'}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 shrink-0">
{t("mintSwap.salesPrice")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white whitespace-nowrap">
{isVaultInfoLoading && !vaultInfoTimedOut ? '...' : ytPriceDisplay}
</span>
</div>
</div>
{/* Submit Button */}
<Button
isDisabled={isConnected && (
!isValidAmount(amount) ||
isDepositLoading ||
isWithdrawLoading ||
((isMatured || isFull) && activeAction === 'deposit') ||
(!isMatured && activeAction === 'withdraw')
)}
color="default"
variant="solid"
className={buttonStyles({ intent: "theme" })}
endContent={<Image src="/components/common/icon11.svg" alt="" width={20} height={20} />}
onPress={() => {
if (!isConnected) { open(); return; }
if (amount && parseFloat(amount) > 0) {
if (activeAction === "deposit") {
executeApproveAndDeposit(amount);
} else {
executeApproveAndWithdraw(amount);
}
}
}}
>
{!isConnected
? t("common.connectWallet")
: isFull && activeAction === 'deposit'
? t("mintSwap.poolFull")
: isMatured && activeAction === 'deposit'
? t("mintSwap.productMatured").replace("{date}", maturityDateStr)
: activeAction === 'deposit'
? depositStatus === 'approving' ? t("common.approving")
: depositStatus === 'approved' ? t("mintSwap.approvedDepositing")
: depositStatus === 'depositing' ? t("mintSwap.depositing")
: depositStatus === 'success' ? t("common.success")
: depositStatus === 'error' ? t("common.failed")
: !!amount && !isValidAmount(amount) ? t("common.invalidAmount")
: t("mintSwap.approveDeposit")
: !isMatured
? nextRedemptionTime > 0
? t("mintSwap.withdrawNotMatured").replace("{date}", maturityDateStr)
: t("mintSwap.withdrawNoMaturity")
: withdrawStatus === 'approving' ? t("common.approving")
: withdrawStatus === 'approved' ? t("mintSwap.approvedWithdrawing")
: withdrawStatus === 'withdrawing' ? t("mintSwap.withdrawing")
: withdrawStatus === 'success' ? t("common.success")
: withdrawStatus === 'error' ? t("common.failed")
: !!amount && !isValidAmount(amount) ? t("common.invalidAmount")
: t("mintSwap.approveWithdraw")
}
</Button>
{/* Review Modal for Deposit */}
<ReviewModal
isOpen={isReviewModalOpen}
onClose={() => setIsReviewModalOpen(false)}
amount={amount}
/>
{/* Withdraw Modal */}
<WithdrawModal
isOpen={isWithdrawModalOpen}
onClose={() => setIsWithdrawModalOpen(false)}
amount={amount}
/>
{/* Terms */}
<div className="flex flex-col gap-0 text-center">
<div className="text-caption-tiny font-regular">
<span className="text-[#9ca1af] dark:text-gray-400">
{t("mintSwap.termsText")}{" "}
</span>
<span className="text-[#10b981] dark:text-green-400">
{t("mintSwap.termsOfService")}
</span>
<span className="text-[#9ca1af] dark:text-gray-400">
{" "}{t("mintSwap.and")}
</span>
</div>
<span className="text-caption-tiny font-regular text-[#10b981] dark:text-green-400">
{t("mintSwap.privacyPolicy")}
</span>
</div>
</div>
</div>
);
}