Files
assetx/webapp/components/product/MintSwapPanel.tsx

504 lines
22 KiB
TypeScript
Raw Normal View History

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