163 lines
7.0 KiB
TypeScript
163 lines
7.0 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect, useMemo } from "react";
|
||
|
|
import Image from "next/image";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { useApp } from "@/contexts/AppContext";
|
||
|
|
import { useQuery } from "@tanstack/react-query";
|
||
|
|
import { useReadContract } from "wagmi";
|
||
|
|
import { fetchContracts } from "@/lib/api/contracts";
|
||
|
|
import { abis } from "@/lib/contracts";
|
||
|
|
import { ProductDetail } from "@/lib/api/fundmarket";
|
||
|
|
|
||
|
|
interface ProductHeaderProps {
|
||
|
|
product: ProductDetail;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ProductHeader({ product }: ProductHeaderProps) {
|
||
|
|
const { t } = useApp();
|
||
|
|
const [copied, setCopied] = useState(false);
|
||
|
|
|
||
|
|
// 从合约读取状态
|
||
|
|
const { data: contractConfigs = [] } = useQuery({
|
||
|
|
queryKey: ['contract-registry'],
|
||
|
|
queryFn: fetchContracts,
|
||
|
|
staleTime: 5 * 60 * 1000,
|
||
|
|
});
|
||
|
|
const factoryAddress = useMemo(() => {
|
||
|
|
const c = contractConfigs.find(c => c.name === 'YTAssetFactory' && c.chain_id === product.chainId);
|
||
|
|
return c?.address as `0x${string}` | undefined;
|
||
|
|
}, [contractConfigs, product.chainId]);
|
||
|
|
|
||
|
|
const { data: vaultInfo, isLoading: isVaultLoading } = useReadContract({
|
||
|
|
address: factoryAddress,
|
||
|
|
abi: abis.YTAssetFactory as any,
|
||
|
|
functionName: 'getVaultInfo',
|
||
|
|
args: product.contractAddress ? [product.contractAddress as `0x${string}`] : undefined,
|
||
|
|
chainId: product.chainId,
|
||
|
|
query: { enabled: !!factoryAddress && !!product.contractAddress },
|
||
|
|
});
|
||
|
|
|
||
|
|
const [vaultTimedOut, setVaultTimedOut] = useState(false);
|
||
|
|
useEffect(() => {
|
||
|
|
if (!factoryAddress || !product.contractAddress) return;
|
||
|
|
setVaultTimedOut(false);
|
||
|
|
const timer = setTimeout(() => setVaultTimedOut(true), 3000);
|
||
|
|
return () => clearTimeout(timer);
|
||
|
|
}, [factoryAddress, product.contractAddress]);
|
||
|
|
|
||
|
|
const totalSupply: bigint = vaultInfo ? ((vaultInfo as any[])[4] as bigint) ?? 0n : 0n;
|
||
|
|
const hardCap: bigint = vaultInfo ? ((vaultInfo as any[])[5] as bigint) ?? 0n : 0n;
|
||
|
|
const nextRedemptionTime = vaultInfo ? Number((vaultInfo as any[])[8]) : 0;
|
||
|
|
const isMatured = nextRedemptionTime > 0 && nextRedemptionTime * 1000 < Date.now();
|
||
|
|
const isFull = hardCap > 0n && totalSupply >= hardCap;
|
||
|
|
|
||
|
|
type StatusCfg = { label: string; dot: string; bg: string; border: string; text: string };
|
||
|
|
const statusCfg: StatusCfg | null = (() => {
|
||
|
|
if (isVaultLoading && !vaultTimedOut) return null;
|
||
|
|
if (isMatured) return { label: t("product.statusEnded"), dot: "bg-gray-400", bg: "bg-gray-100 dark:bg-gray-700", border: "border-gray-300 dark:border-gray-500", text: "text-gray-500 dark:text-gray-400" };
|
||
|
|
if (isFull) return { label: t("product.statusFull"), dot: "bg-orange-500", bg: "bg-orange-50 dark:bg-orange-900/30", border: "border-orange-200 dark:border-orange-700", text: "text-orange-600 dark:text-orange-400" };
|
||
|
|
return { label: t("product.statusActive"), dot: "bg-green-500", bg: "bg-green-50 dark:bg-green-900/30", border: "border-green-200 dark:border-green-700", text: "text-green-600 dark:text-green-400" };
|
||
|
|
})();
|
||
|
|
|
||
|
|
const shortenAddress = (address: string) => {
|
||
|
|
if (!address || address.length < 10) return address;
|
||
|
|
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const contractAddress = product.contractAddress || '';
|
||
|
|
|
||
|
|
const handleCopy = async () => {
|
||
|
|
if (!contractAddress) return;
|
||
|
|
try {
|
||
|
|
if (navigator.clipboard) {
|
||
|
|
await navigator.clipboard.writeText(contractAddress);
|
||
|
|
} else {
|
||
|
|
// fallback for insecure context (HTTP)
|
||
|
|
const textarea = document.createElement('textarea');
|
||
|
|
textarea.value = contractAddress;
|
||
|
|
textarea.style.position = 'fixed';
|
||
|
|
textarea.style.opacity = '0';
|
||
|
|
document.body.appendChild(textarea);
|
||
|
|
textarea.select();
|
||
|
|
document.execCommand('copy');
|
||
|
|
document.body.removeChild(textarea);
|
||
|
|
}
|
||
|
|
setCopied(true);
|
||
|
|
toast.success(t("product.addressCopied") || "Contract address copied!");
|
||
|
|
setTimeout(() => setCopied(false), 2000);
|
||
|
|
} catch {
|
||
|
|
toast.error(t("product.copyFailed") || "Failed to copy address");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col gap-6">
|
||
|
|
{/* Product Title Section */}
|
||
|
|
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||
|
|
<div className="flex gap-4 md:gap-6">
|
||
|
|
<div className="flex-shrink-0">
|
||
|
|
<Image
|
||
|
|
src={product.iconUrl || '/assets/tokens/default.svg'}
|
||
|
|
alt={product.name}
|
||
|
|
width={80}
|
||
|
|
height={80}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex flex-col gap-2">
|
||
|
|
<div className="flex items-center gap-2 flex-wrap">
|
||
|
|
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white">
|
||
|
|
{product.name}
|
||
|
|
</h1>
|
||
|
|
{statusCfg && (
|
||
|
|
<div className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 ${statusCfg.bg} ${statusCfg.border}`}>
|
||
|
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusCfg.dot}`} />
|
||
|
|
<span className={`text-caption-tiny font-semibold ${statusCfg.text}`}>
|
||
|
|
{statusCfg.label}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<p className="text-body-default font-regular text-text-tertiary dark:text-gray-400">
|
||
|
|
{product.subtitle}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
onClick={handleCopy}
|
||
|
|
disabled={!contractAddress}
|
||
|
|
title={contractAddress || ''}
|
||
|
|
className="self-start"
|
||
|
|
style={{
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
gap: '6px',
|
||
|
|
padding: '6px 12px',
|
||
|
|
borderRadius: '9999px',
|
||
|
|
border: '1px solid #CADFFF',
|
||
|
|
backgroundColor: '#EBF2FF',
|
||
|
|
color: '#1447E6',
|
||
|
|
cursor: contractAddress ? 'pointer' : 'not-allowed',
|
||
|
|
opacity: contractAddress ? 1 : 0.4,
|
||
|
|
flexShrink: 0,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{copied ? (
|
||
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ color: '#22c55e', flexShrink: 0 }}>
|
||
|
|
<path d="M2 7l3.5 3.5L12 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||
|
|
</svg>
|
||
|
|
) : (
|
||
|
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ color: '#1447E6', flexShrink: 0 }}>
|
||
|
|
<rect x="4" y="4" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.2"/>
|
||
|
|
<path d="M4 3.5V3a1.5 1.5 0 0 1 1.5-1.5H11A1.5 1.5 0 0 1 12.5 3v5.5A1.5 1.5 0 0 1 11 10h-.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
|
||
|
|
</svg>
|
||
|
|
)}
|
||
|
|
<span className="text-caption-tiny font-medium font-inter">
|
||
|
|
{t("product.contractAddress")}: {shortenAddress(contractAddress) || '--'}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|