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

332 lines
14 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, useCallback } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useUSDCBalance } from '@/hooks/useBalance';
import { useLendingSupply, useSuppliedBalance } from '@/hooks/useLendingSupply';
import { getTxUrl } from '@/lib/contracts';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useHealthFactor, useSupplyAPY } from '@/hooks/useHealthFactor';
import { toast } from "sonner";
import { useWalletStatus } from '@/hooks/useWalletStatus';
import TokenSelector from '@/components/common/TokenSelector';
import { useAppKit } from "@reown/appkit/react";
import { Token } from '@/lib/api/tokens';
type StablecoinType = 'USDC' | 'USDT';
export default function SupplyPanel() {
const { t } = useApp();
const [amount, setAmount] = useState("");
// 稳定币选择(用于余额显示)
const [stablecoin, setStablecoin] = useState<StablecoinType>('USDC');
const [stablecoinObj, setStablecoinObj] = useState<Token | undefined>();
// 使用统一的钱包状态 Hook
const { address, isConnected, chainId, mounted } = useWalletStatus();
const { open } = useAppKit();
// 处理稳定币选择
const handleStablecoinSelect = useCallback((token: Token) => {
setStablecoinObj(token);
setStablecoin(token.symbol as StablecoinType);
setAmount(""); // 清空输入金额
}, []);
// 优先从产品合约地址读取精度,失败时回退到 token.decimals
const usdcInputDecimals = useTokenDecimalsFromAddress(
stablecoinObj?.contractAddress,
stablecoinObj?.decimals ?? 18
);
const usdcDisplayDecimals = Math.min(usdcInputDecimals, 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;
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 > usdcDisplayDecimals) return;
if (parseFloat(value) > parseFloat(usdcBalance)) { setAmount(truncateDecimals(usdcBalance, usdcDisplayDecimals)); return; }
setAmount(value);
};
const { formattedBalance: usdcBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useUSDCBalance();
const { formattedBalance: suppliedBalance, refetch: refetchSupplied } = useSuppliedBalance();
// 根据选择的 token 类型决定使用哪个 hook
// 目前只支持 USDC supplyYT token 选择仅用于显示
const {
status: supplyStatus,
error: supplyError,
isLoading: isSupplyLoading,
approveHash,
supplyHash,
executeApproveAndSupply,
reset: resetSupply,
} = useLendingSupply();
// 健康因子和 APY
const {
formattedHealthFactor,
status: healthStatus,
utilization,
isLoading: isHealthLoading,
refetch: refetchHealthFactor,
} = useHealthFactor();
const { apy } = useSupplyAPY();
const SUPPLY_TOAST_ID = 'lending-supply-tx';
// Approve 交易提交
useEffect(() => {
if (approveHash && supplyStatus === 'approving') {
toast.loading(t("supply.toast.approvalSubmitted"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.waitingConfirmation"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(approveHash), '_blank'),
},
});
}
}, [approveHash, supplyStatus]);
// Supply 交易提交
useEffect(() => {
if (supplyHash && supplyStatus === 'supplying') {
toast.loading(t("supply.toast.supplySubmitted"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.supplyingNow"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(supplyHash), '_blank'),
},
});
}
}, [supplyHash, supplyStatus]);
// 成功后刷新余额
useEffect(() => {
if (supplyStatus === 'success') {
toast.success(t("supply.toast.suppliedSuccess"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.suppliedSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchSupplied();
refetchHealthFactor();
const timer = setTimeout(() => { resetSupply(); setAmount(''); }, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [supplyStatus]);
// 错误提示
useEffect(() => {
if (supplyError) {
if (supplyError === 'Transaction cancelled') {
toast.dismiss(SUPPLY_TOAST_ID);
} else {
toast.error(t("supply.toast.supplyFailed"), {
id: SUPPLY_TOAST_ID,
duration: 5000,
});
}
}
}, [supplyError]);
return (
<div className="p-6 flex flex-col gap-6 flex-1">
{/* Token Balance & Supplied */}
<div className="flex flex-col gap-4">
{/* Token Balance */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-2">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.tokenBalance")}
</span>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1 flex-1">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
{/* 稳定币选择器 */}
<div className="flex-shrink-0">
<TokenSelector
selectedToken={stablecoinObj}
onSelect={handleStablecoinSelect}
filterTypes={['stablecoin']}
defaultSymbol={stablecoin}
/>
</div>
</div>
</div>
{/* Supplied - 同步显示选中的稳定币 */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.supplied")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : parseFloat(suppliedBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{stablecoin}
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : parseFloat(suppliedBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}
</span>
</div>
</div>
{/* Deposit Section */}
<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-4">
{/* Deposit Label and Available */}
<div className="flex items-center gap-2">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("supply.deposit")}
</span>
<div className="flex items-center gap-2 ml-auto">
<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-300 leading-[150%] tracking-[0.01em]">
{!mounted ? `0 ${stablecoin}` : isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} ${stablecoin}`}
</span>
</div>
<Button
size="sm"
color="default"
variant="solid"
className={buttonStyles({ intent: "max" })}
onPress={() => setAmount(truncateDecimals(usdcBalance, usdcDisplayDecimals))}
>
{t("supply.max")}
</Button>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-4 h-12">
{/* Amount Input */}
<div className="flex flex-col items-end flex-1">
<input
type="text" inputMode="decimal"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder="0.00"
className="text-heading-h3 font-bold text-text-input-box dark:text-gray-500 leading-[130%] tracking-[-0.005em] text-right bg-transparent outline-none w-full"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
--
</span>
</div>
</div>
</div>
{/* Health Factor */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.healthFactor")}: {isHealthLoading ? '...' : formattedHealthFactor}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{utilization.toFixed(1)}% {t("supply.utilization")}
</span>
</div>
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${
healthStatus === 'safe' ? 'text-[#10b981] dark:text-green-400' :
healthStatus === 'warning' ? 'text-[#ffb933] dark:text-yellow-400' :
healthStatus === 'danger' ? 'text-[#ff6900] dark:text-orange-400' :
'text-[#ef4444] dark:text-red-400'
}`}>
{healthStatus === 'safe' ? t("supply.safe") :
healthStatus === 'warning' ? t("supply.warning") :
healthStatus === 'danger' ? t("supply.danger") :
t("supply.critical")}
</span>
</div>
{/* Rainbow Gradient Progress Bar */}
<div className="relative w-full h-2.5 rounded-full overflow-hidden">
{/* Background Rainbow Gradient */}
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
}}
/>
{/* Active Progress with Rainbow Gradient */}
<div
className="absolute left-0 top-0 h-full rounded-full transition-all duration-500 ease-out"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
width: `${Math.min(utilization, 100)}%`,
boxShadow: utilization > 0 ? '0 0 8px rgba(59, 130, 246, 0.5)' : 'none',
}}
/>
</div>
</div>
{/* Estimated Returns */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-2">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-300 leading-[150%]">
{t("supply.estimatedReturns")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.estApy")}
</span>
<span className="text-body-small font-bold text-[#ff6900] dark:text-orange-400 leading-[150%]">
{apy > 0 ? `${apy.toFixed(2)}%` : '--'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.estReturnsYear")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 leading-[150%]">
{amount && apy > 0
? `~ $${(parseFloat(amount) * apy / 100).toFixed(2)}`
: '~ $0.00'}
</span>
</div>
</div>
{/* Supply Button */}
<Button
isDisabled={!mounted || (isConnected && (!isValidAmount(amount) || isSupplyLoading))}
color="default"
variant="solid"
className={`${buttonStyles({ intent: "theme" })} mt-auto`}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={() => {
if (!isConnected) { open(); return; }
if (amount && parseFloat(amount) > 0) {
resetSupply();
executeApproveAndSupply(amount);
}
}}
>
{!mounted && t("common.loading")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && supplyStatus === 'idle' && !!amount && !isValidAmount(amount) && t("common.invalidAmount")}
{mounted && isConnected && supplyStatus === 'idle' && (!amount || isValidAmount(amount)) && t("supply.supply")}
{mounted && supplyStatus === 'approving' && t("common.approving")}
{mounted && supplyStatus === 'approved' && t("supply.approvedSupplying")}
{mounted && supplyStatus === 'supplying' && t("supply.supplying")}
{mounted && supplyStatus === 'success' && t("common.success")}
{mounted && supplyStatus === 'error' && t("common.failed")}
</Button>
</div>
);
}