init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
331
webapp/components/lending/supply/SupplyPanel.tsx
Normal file
331
webapp/components/lending/supply/SupplyPanel.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"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 supply,YT 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user