包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import Image from "next/image";
|
|
import { Button } from "@heroui/react";
|
|
import { useApp } from "@/contexts/AppContext";
|
|
import { buttonStyles } from "@/lib/buttonStyles";
|
|
import { useAccount } from "wagmi";
|
|
import { useReadContract } from "wagmi";
|
|
import { formatUnits } from "viem";
|
|
import { useLendingSupply, useSuppliedBalance } from "@/hooks/useLendingSupply";
|
|
import { useLendingWithdraw } from "@/hooks/useLendingWithdraw";
|
|
import { useBorrowBalance, useMaxBorrowable } from "@/hooks/useCollateral";
|
|
import { notifyLendingBorrow, notifyLendingRepay } from "@/lib/api/lending";
|
|
import { useAppKit } from "@reown/appkit/react";
|
|
import { toast } from "sonner";
|
|
import { getTxUrl, getContractAddress, abis } from "@/lib/contracts";
|
|
import { useTokenDecimalsFromAddress } from "@/hooks/useTokenDecimals";
|
|
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
|
|
|
|
interface RepayBorrowDebtProps {
|
|
refreshTrigger?: number;
|
|
}
|
|
|
|
export default function RepayBorrowDebt({ refreshTrigger }: RepayBorrowDebtProps) {
|
|
const { t } = useApp();
|
|
const { isConnected } = useAccount();
|
|
const { open } = useAppKit();
|
|
const [mounted, setMounted] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<'borrow' | 'repay'>('borrow');
|
|
const [amount, setAmount] = useState('');
|
|
|
|
useEffect(() => { setMounted(true); }, []);
|
|
|
|
// On-chain data
|
|
const { formattedBalance: suppliedBalance, refetch: refetchSupply } = useSuppliedBalance();
|
|
const { formattedBalance: borrowedBalance, refetch: refetchBorrow } = useBorrowBalance();
|
|
const { formattedAvailable, available, refetch: refetchMaxBorrowable } = useMaxBorrowable();
|
|
|
|
// Refresh when collateral changes (triggered by RepaySupplyCollateral)
|
|
useEffect(() => {
|
|
if (refreshTrigger === undefined || refreshTrigger === 0) return;
|
|
refetchBorrow();
|
|
refetchMaxBorrowable();
|
|
}, [refreshTrigger]);
|
|
|
|
// Borrow = withdraw (takes USDC from protocol, increasing debt)
|
|
const {
|
|
status: borrowStatus,
|
|
error: borrowError,
|
|
isLoading: isBorrowing,
|
|
withdrawHash: borrowHash,
|
|
executeWithdraw: executeBorrow,
|
|
reset: resetBorrow,
|
|
} = useLendingWithdraw();
|
|
|
|
// Repay = supply USDC (reduces debt)
|
|
const {
|
|
status: repayStatus,
|
|
error: repayError,
|
|
isLoading: isRepaying,
|
|
approveHash: repayApproveHash,
|
|
supplyHash: repayHash,
|
|
executeApproveAndSupply: executeRepay,
|
|
reset: resetRepay,
|
|
} = useLendingSupply();
|
|
|
|
const BORROW_TOAST_ID = 'lending-borrow-tx';
|
|
const REPAY_TOAST_ID = 'lending-repay-tx';
|
|
|
|
// Borrow 交易提交 toast
|
|
useEffect(() => {
|
|
if (borrowHash && borrowStatus === 'withdrawing') {
|
|
toast.loading(t("repay.toast.borrowSubmitted"), {
|
|
id: BORROW_TOAST_ID,
|
|
description: t("repay.toast.borrowingNow"),
|
|
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(borrowHash), '_blank') },
|
|
});
|
|
}
|
|
}, [borrowHash, borrowStatus]);
|
|
|
|
// Repay approve 交易提交 toast
|
|
useEffect(() => {
|
|
if (repayApproveHash && repayStatus === 'approving') {
|
|
toast.loading(t("repay.toast.approvalSubmitted"), {
|
|
id: REPAY_TOAST_ID,
|
|
description: t("repay.toast.waitingConfirmation"),
|
|
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(repayApproveHash), '_blank') },
|
|
});
|
|
}
|
|
}, [repayApproveHash, repayStatus]);
|
|
|
|
// Repay 交易提交 toast
|
|
useEffect(() => {
|
|
if (repayHash && repayStatus === 'supplying') {
|
|
toast.loading(t("repay.toast.repaySubmitted"), {
|
|
id: REPAY_TOAST_ID,
|
|
description: t("repay.toast.repayingNow"),
|
|
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(repayHash), '_blank') },
|
|
});
|
|
}
|
|
}, [repayHash, repayStatus]);
|
|
|
|
// Notify backend after borrow
|
|
useEffect(() => {
|
|
if (borrowStatus === 'success' && borrowHash && amount) {
|
|
toast.success(t("repay.toast.borrowSuccess"), {
|
|
id: BORROW_TOAST_ID,
|
|
description: t("repay.toast.borrowSuccessDesc"),
|
|
duration: 5000,
|
|
});
|
|
notifyLendingBorrow(amount, borrowHash);
|
|
setTimeout(() => refetchBorrow(), 2000);
|
|
setAmount('');
|
|
setTimeout(resetBorrow, 3000);
|
|
}
|
|
}, [borrowStatus]);
|
|
|
|
// Notify backend after repay
|
|
useEffect(() => {
|
|
if (repayStatus === 'success' && repayHash && amount) {
|
|
toast.success(t("repay.toast.repaySuccess"), {
|
|
id: REPAY_TOAST_ID,
|
|
description: t("repay.toast.repaySuccessDesc"),
|
|
duration: 5000,
|
|
});
|
|
notifyLendingRepay(amount, repayHash);
|
|
setTimeout(() => { refetchBorrow(); refetchSupply(); }, 2000);
|
|
setAmount('');
|
|
setTimeout(resetRepay, 3000);
|
|
}
|
|
}, [repayStatus]);
|
|
|
|
// 错误提示
|
|
useEffect(() => {
|
|
if (borrowError) {
|
|
if (borrowError === 'Transaction cancelled') {
|
|
toast.dismiss(BORROW_TOAST_ID);
|
|
} else {
|
|
toast.error(t("repay.toast.borrowFailed"), { id: BORROW_TOAST_ID, duration: 5000 });
|
|
}
|
|
}
|
|
}, [borrowError]);
|
|
|
|
useEffect(() => {
|
|
if (repayError) {
|
|
if (repayError === 'Transaction cancelled') {
|
|
toast.dismiss(REPAY_TOAST_ID);
|
|
} else {
|
|
toast.error(t("repay.toast.repayFailed"), { id: REPAY_TOAST_ID, duration: 5000 });
|
|
}
|
|
}
|
|
}, [repayError]);
|
|
|
|
// 从产品 API 获取 USDC token 信息,优先从合约地址读取精度
|
|
const usdcToken = useTokenBySymbol('USDC');
|
|
const { chainId } = useAccount();
|
|
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined;
|
|
const usdcInputDecimals = useTokenDecimalsFromAddress(usdcToken?.contractAddress, usdcToken?.decimals ?? 18);
|
|
|
|
// 最小借贷数量
|
|
const { data: baseBorrowMinRaw } = useReadContract({
|
|
address: lendingProxyAddress,
|
|
abi: abis.lendingProxy,
|
|
functionName: 'baseBorrowMin',
|
|
query: { enabled: !!lendingProxyAddress },
|
|
});
|
|
const baseBorrowMin = baseBorrowMinRaw != null
|
|
? parseFloat(formatUnits(baseBorrowMinRaw as bigint, usdcInputDecimals))
|
|
: 0;
|
|
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 isBelowMin = activeTab === 'borrow' && isValidAmount(amount) && baseBorrowMin > 0 && parseFloat(amount) < baseBorrowMin;
|
|
|
|
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;
|
|
const maxBalance = activeTab === 'borrow' ? parseFloat(formattedAvailable) : parseFloat(borrowedBalance);
|
|
if (parseFloat(value) > maxBalance) {
|
|
setAmount(truncateDecimals(activeTab === 'borrow' ? formattedAvailable : borrowedBalance, usdcDisplayDecimals));
|
|
return;
|
|
}
|
|
setAmount(value);
|
|
};
|
|
|
|
const handleAction = () => {
|
|
if (!amount || parseFloat(amount) <= 0) return;
|
|
if (activeTab === 'borrow') {
|
|
executeBorrow(amount);
|
|
} else {
|
|
executeRepay(amount);
|
|
}
|
|
};
|
|
|
|
const handleMax = () => {
|
|
if (activeTab === 'repay') {
|
|
setAmount(truncateDecimals(borrowedBalance, usdcDisplayDecimals));
|
|
} else if (activeTab === 'borrow') {
|
|
setAmount(truncateDecimals(formattedAvailable, usdcDisplayDecimals));
|
|
}
|
|
};
|
|
|
|
const isLoading = isBorrowing || isRepaying;
|
|
const currentStatus = activeTab === 'borrow' ? borrowStatus : repayStatus;
|
|
|
|
const getButtonLabel = () => {
|
|
if (!isConnected) return t("common.connectWallet");
|
|
if (currentStatus === 'approving') return t("repay.approvingUsdc");
|
|
if (currentStatus === 'approved') return t("repay.confirmedProcessing");
|
|
if (currentStatus === 'withdrawing') return t("repay.borrowing");
|
|
if (currentStatus === 'supplying') return t("repay.repaying");
|
|
if (currentStatus === 'success') return t("common.success");
|
|
if (currentStatus === 'error') return t("common.failed");
|
|
if (amount && !isValidAmount(amount)) return t("common.invalidAmount");
|
|
if (isBelowMin) return t("repay.minBorrow").replace('{{amount}}', baseBorrowMin.toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals }));
|
|
return activeTab === 'borrow' ? t("repay.borrow") : t("repay.repay");
|
|
};
|
|
|
|
return (
|
|
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8 flex flex-col gap-6 flex-1 shadow-md">
|
|
{/* Title */}
|
|
<h3 className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
|
|
{t("repay.borrowDebt")}
|
|
</h3>
|
|
|
|
{/* USDC Balance Info */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-10 h-10 rounded-full overflow-hidden shadow-md flex-shrink-0">
|
|
<Image
|
|
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
|
|
alt="USDC"
|
|
width={40}
|
|
height={40}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col min-w-0">
|
|
<div className="flex items-baseline gap-1">
|
|
<span className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em] truncate">
|
|
{!mounted ? '--' : parseFloat(truncateDecimals(borrowedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
|
|
</span>
|
|
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400 flex-shrink-0">USDC</span>
|
|
</div>
|
|
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
|
|
{t("repay.borrowed")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end flex-shrink-0">
|
|
<div className="flex items-baseline gap-1">
|
|
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 whitespace-nowrap">
|
|
{!mounted ? '--' : parseFloat(truncateDecimals(formattedAvailable, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
|
|
</span>
|
|
<span className="text-caption-tiny font-bold text-[#10b981] dark:text-green-400 flex-shrink-0">USDC</span>
|
|
</div>
|
|
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 whitespace-nowrap">
|
|
{t("repay.availableToBorrow")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Switcher */}
|
|
<div className="flex rounded-xl bg-fill-secondary-click dark:bg-gray-700 p-1">
|
|
<button
|
|
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
|
|
activeTab === 'borrow'
|
|
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
|
|
: 'text-text-tertiary dark:text-gray-400'
|
|
}`}
|
|
onClick={() => { setActiveTab('borrow'); setAmount(''); resetBorrow(); }}
|
|
>
|
|
{t("repay.borrow")}
|
|
</button>
|
|
<button
|
|
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
|
|
activeTab === 'repay'
|
|
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
|
|
: 'text-text-tertiary dark:text-gray-400'
|
|
}`}
|
|
onClick={() => { setActiveTab('repay'); setAmount(''); resetRepay(); }}
|
|
>
|
|
{t("repay.repay")}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Amount Input */}
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
|
|
{t("repay.amount")} (USDC)
|
|
</span>
|
|
{activeTab === 'borrow' && mounted && available > 0 && (
|
|
<button
|
|
onClick={handleMax}
|
|
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
|
|
>
|
|
{t("common.max")}: {parseFloat(truncateDecimals(formattedAvailable, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} USDC
|
|
</button>
|
|
)}
|
|
{activeTab === 'repay' && mounted && parseFloat(borrowedBalance) > 0 && (
|
|
<button
|
|
onClick={handleMax}
|
|
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
|
|
>
|
|
{t("common.max")}: {parseFloat(truncateDecimals(borrowedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} USDC
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center bg-bg-subtle dark:bg-gray-700 rounded-xl px-4 h-[52px] gap-2">
|
|
<input
|
|
type="text" inputMode="decimal"
|
|
placeholder="0.00"
|
|
value={amount}
|
|
onChange={(e) => handleAmountChange(e.target.value)}
|
|
className="flex-1 bg-transparent text-body-default font-bold text-text-primary dark:text-white outline-none"
|
|
/>
|
|
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400">
|
|
USDC
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Button */}
|
|
<Button
|
|
isDisabled={!mounted || (isConnected && (!isValidAmount(amount) || isBelowMin || isLoading || currentStatus === 'success'))}
|
|
color="default"
|
|
className={buttonStyles({ intent: "theme" })}
|
|
onPress={() => !isConnected ? open() : handleAction()}
|
|
>
|
|
{!mounted ? 'Loading...' : getButtonLabel()}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|