init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
233
webapp/components/product/APYHistoryCard.tsx
Normal file
233
webapp/components/product/APYHistoryCard.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { fetchProductHistory, HistoryPoint } from "@/lib/api/fundmarket";
|
||||
import * as echarts from "echarts";
|
||||
|
||||
interface APYHistoryCardProps {
|
||||
productId: number;
|
||||
}
|
||||
|
||||
export default function APYHistoryCard({ productId }: APYHistoryCardProps) {
|
||||
const { t } = useApp();
|
||||
const [activeTab, setActiveTab] = useState<"apy" | "price">("apy");
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
|
||||
const { data: history = [] } = useQuery<HistoryPoint[]>({
|
||||
queryKey: ["product-history", productId],
|
||||
queryFn: () => fetchProductHistory(productId),
|
||||
staleTime: 60 * 60 * 1000, // 1h
|
||||
});
|
||||
|
||||
const colors = [
|
||||
"#FBEADE", "#F5D4BE", "#EFBF9E", "#E9AA7E", "#E3955E",
|
||||
"#DD804E", "#D76B3E", "#D1562E", "#C65122",
|
||||
];
|
||||
|
||||
const isEmpty = history.length < 2;
|
||||
|
||||
const labels = isEmpty ? [] : history.map((p) => {
|
||||
const d = new Date(p.time);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
});
|
||||
const apyData = isEmpty ? [] : history.map((p) => p.apy);
|
||||
const priceData = isEmpty ? [] : history.map((p) => p.price);
|
||||
|
||||
const activeData = activeTab === "apy" ? apyData : priceData;
|
||||
const highest = activeData.length > 0 ? Math.max(...activeData) : 0;
|
||||
const lowest = activeData.length > 0 ? Math.min(...activeData) : 0;
|
||||
|
||||
const updateChart = () => {
|
||||
if (!chartInstance.current) return;
|
||||
|
||||
// price tab 自适应 Y 轴,上下各留 20% padding;APY bar 图保持从 0 开始
|
||||
let yAxisMin: number | undefined;
|
||||
let yAxisMax: number | undefined;
|
||||
if (activeTab === "price" && priceData.length > 0) {
|
||||
const yMin = Math.min(...priceData);
|
||||
const yMax = Math.max(...priceData);
|
||||
const range = yMax - yMin || yMax * 0.01 || 0.01;
|
||||
yAxisMin = yMin - range * 0.2;
|
||||
yAxisMax = yMax + range * 0.2;
|
||||
}
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
grid: { left: 2, right: 2, top: 10, bottom: 0 },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
show: true,
|
||||
confine: true,
|
||||
backgroundColor: "rgba(17, 24, 39, 0.9)",
|
||||
borderColor: "#374151",
|
||||
textStyle: { color: "#f9fafb", fontSize: 12, fontWeight: 500 },
|
||||
formatter: (params: any) => {
|
||||
const d = params[0];
|
||||
const suffix = activeTab === "apy" ? "%" : " USDC";
|
||||
return `<div style="padding:4px 8px">
|
||||
<span style="color:#9ca3af;font-size:11px">${labels[d.dataIndex]}</span><br/>
|
||||
<span style="color:#10b981;font-weight:600;font-size:14px">${Number(d.value).toFixed(activeTab === "apy" ? 2 : 4)}${suffix}</span>
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: labels,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
show: labels.length > 0,
|
||||
color: "#9ca3af",
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
interval: Math.max(0, Math.floor(labels.length / 7) - 1),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
min: yAxisMin,
|
||||
max: yAxisMax,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
series: [
|
||||
activeTab === "apy"
|
||||
? {
|
||||
data: apyData.map((value, index) => ({
|
||||
value,
|
||||
itemStyle: {
|
||||
color: colors[index % colors.length],
|
||||
borderRadius: [2, 2, 0, 0],
|
||||
},
|
||||
})),
|
||||
type: "bar",
|
||||
barWidth: "60%",
|
||||
barMaxWidth: 24,
|
||||
barMinHeight: 2,
|
||||
}
|
||||
: {
|
||||
data: priceData,
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: "#10b981", width: 2 },
|
||||
itemStyle: { color: "#10b981" },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: "rgba(16,185,129,0.3)" },
|
||||
{ offset: 1, color: "rgba(16,185,129,0)" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartInstance.current.setOption(option, true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame to ensure the container has been laid out
|
||||
// before ECharts tries to measure its dimensions
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!chartRef.current) return;
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
updateChart();
|
||||
chartInstance.current?.resize();
|
||||
});
|
||||
const handleResize = () => chartInstance.current?.resize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [activeTab, history]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { chartInstance.current?.dispose(); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6 flex flex-col gap-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-6 border-b border-border-gray dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab("apy")}
|
||||
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
|
||||
activeTab === "apy"
|
||||
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
|
||||
: "text-text-tertiary dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{t("apy.apyHistory")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("price")}
|
||||
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
|
||||
activeTab === "price"
|
||||
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
|
||||
: "text-text-tertiary dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{t("apy.priceHistory")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chart Area */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
|
||||
{isEmpty ? t("apy.lastDays") : `${t("apy.lastDays")} (${history.length} snapshots)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ECharts Chart */}
|
||||
{isEmpty ? (
|
||||
<div className="w-full h-[140px] flex items-center justify-center text-caption-tiny text-text-tertiary dark:text-gray-500">
|
||||
{t("common.noData")}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={chartRef}
|
||||
className="w-full"
|
||||
style={{
|
||||
height: "140px",
|
||||
background: activeTab === "price"
|
||||
? "linear-gradient(0deg, rgba(16, 185, 129, 0.1) 0%, transparent 100%)"
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<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("apy.highest")}
|
||||
</span>
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{isEmpty ? '--' : activeTab === "apy" ? `${highest.toFixed(2)}%` : `$${highest.toFixed(4)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
|
||||
{t("apy.lowest")}
|
||||
</span>
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{isEmpty ? '--' : activeTab === "apy" ? `${lowest.toFixed(2)}%` : `$${lowest.toFixed(4)}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
349
webapp/components/product/AssetCustodyVerification.tsx
Normal file
349
webapp/components/product/AssetCustodyVerification.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { CheckCircle, XCircle, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@heroui/react";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { ProductDetail } from "@/lib/api/fundmarket";
|
||||
|
||||
interface AssetCustodyVerificationProps {
|
||||
product: ProductDetail;
|
||||
}
|
||||
|
||||
interface VerificationCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
reportUrl?: string;
|
||||
}
|
||||
|
||||
function VerificationCard({ icon, title, description, buttonText, reportUrl }: VerificationCardProps) {
|
||||
return (
|
||||
<button
|
||||
className="bg-bg-surface dark:bg-gray-700 rounded-2xl border border-border-normal dark:border-gray-600 p-6 flex flex-col gap-4 text-left transition-all duration-200 hover:-translate-y-1 hover:shadow-lg hover:border-gray-300 dark:hover:border-gray-500 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none w-full"
|
||||
onClick={() => reportUrl && window.open(reportUrl, "_blank")}
|
||||
disabled={!reportUrl}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-5 h-5 flex-shrink-0">
|
||||
<Image src={icon} alt={title} width={20} height={20} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-body-default font-bold text-text-primary dark:text-white">
|
||||
{title}
|
||||
</h4>
|
||||
<p className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-auto pt-2">
|
||||
<span className="text-[10px] font-bold leading-[150%] tracking-[0.01em] text-[#9ca1af] dark:text-gray-400">
|
||||
{buttonText}
|
||||
</span>
|
||||
<Image src="/components/product/component-118.svg" alt="" width={16} height={16} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name.split(/\s+/).map(w => w[0]).slice(0, 2).join("").toUpperCase();
|
||||
}
|
||||
|
||||
function formatUSD(v: number) {
|
||||
return "$" + v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatMaturityDate(dateStr: string) {
|
||||
if (!dateStr) return "--";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
export default function AssetCustodyVerification({ product }: AssetCustodyVerificationProps) {
|
||||
const { t } = useApp();
|
||||
|
||||
const custody = product.custody;
|
||||
const extra = custody?.additionalInfo ?? {};
|
||||
const assetType: string = extra.asset_type ?? "--";
|
||||
const maturityDate: string = extra.maturity_date ? formatMaturityDate(extra.maturity_date as string) : "--";
|
||||
const daysRemaining: number | null = typeof extra.days_remaining === "number" ? extra.days_remaining : null;
|
||||
const custodyValueUSD: number = typeof extra.custody_value_usd === "number" ? extra.custody_value_usd : 0;
|
||||
const verificationStatus: string = typeof extra.verification_status === "string" ? extra.verification_status : "Unverified";
|
||||
const isVerified = verificationStatus.toLowerCase() === "verified";
|
||||
const auditReportUrl = custody?.auditReportUrl || "";
|
||||
|
||||
// 动态获取 verification 区域的链接,按 displayOrder 排序
|
||||
const verificationLinks = (product.productLinks ?? [])
|
||||
.filter((l) => l.displayArea === 'verification' || l.displayArea === 'both')
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder);
|
||||
|
||||
// 循环使用的图标列表
|
||||
const ICONS = [
|
||||
"/components/product/component-117.svg",
|
||||
"/components/product/component-119.svg",
|
||||
"/components/product/component-121.svg",
|
||||
];
|
||||
|
||||
// Get attestation reports for Independent Verifications
|
||||
const attestationReports = (product.auditReports ?? []).filter(
|
||||
(r) => r.reportType === "attestation"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-body-large font-bold text-text-primary dark:text-white">
|
||||
{t("custody.title")}
|
||||
</h2>
|
||||
<p className="text-body-small font-regular text-[#9ca1af] dark:text-gray-400">
|
||||
{t("custody.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Holdings Table Card */}
|
||||
<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">
|
||||
{/* Table Header */}
|
||||
<div className="flex flex-col gap-4 pb-6 border-b border-border-gray dark:border-gray-700">
|
||||
<h3 className="text-body-default font-bold text-text-primary dark:text-white">
|
||||
{t("custody.underlyingHoldings")}
|
||||
</h3>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||
<p className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">
|
||||
{t("custody.verifiedBy")} {custody?.auditorName ?? "--"}
|
||||
</p>
|
||||
{custody?.lastAuditDate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src="/components/product/component-115.svg" alt="" width={16} height={16} />
|
||||
<span className="text-caption-tiny font-medium text-[#9ca1af] dark:text-gray-400">
|
||||
{t("custody.lastUpdated")}: {custody.lastAuditDate}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 移动端:卡片化布局 ── */}
|
||||
<div className="md:hidden flex flex-col gap-4">
|
||||
{/* Custodian 卡片 */}
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-xl">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "linear-gradient(135deg, rgba(255, 137, 4, 1) 0%, rgba(245, 73, 0, 1) 100%)" }}
|
||||
>
|
||||
<span className="text-sm font-bold text-white">
|
||||
{custody?.custodianName ? getInitials(custody.custodianName) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">{custody?.custodianName ?? "--"}</span>
|
||||
<span className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">{custody?.custodyType ?? "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息网格:2列 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Asset Type */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-xl">
|
||||
<span className="text-caption-tiny font-medium text-[#9ca1af] dark:text-gray-400 block mb-1">{t("custody.assetType")}</span>
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">{assetType}</span>
|
||||
</div>
|
||||
{/* Maturity */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-xl">
|
||||
<span className="text-caption-tiny font-medium text-[#9ca1af] dark:text-gray-400 block mb-1">{t("custody.maturity")}</span>
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">{maturityDate}</span>
|
||||
{daysRemaining !== null && (
|
||||
daysRemaining < 0
|
||||
? <span className="text-caption-tiny font-regular text-red-400 block">{t("custody.expired")}</span>
|
||||
: <span className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400 block">({daysRemaining} {t("custody.days")})</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Value USD */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-xl">
|
||||
<span className="text-caption-tiny font-medium text-[#9ca1af] dark:text-gray-400 block mb-1">{t("custody.valueUSD")}</span>
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{custodyValueUSD > 0 ? formatUSD(custodyValueUSD) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Status */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-xl">
|
||||
<span className="text-caption-tiny font-medium text-[#9ca1af] dark:text-gray-400 block mb-1">{t("custody.status")}</span>
|
||||
<div className={`inline-flex rounded-full px-3 py-1 items-center gap-1.5 ${isVerified ? "bg-[#f2fcf7] dark:bg-green-900/20" : "bg-[#f9fafb] dark:bg-gray-600"}`}>
|
||||
{isVerified ? <CheckCircle size={10} className="text-[#10b981] dark:text-green-400" /> : <XCircle size={10} className="text-[#9ca1af] dark:text-gray-400" />}
|
||||
<span className={`text-[10px] font-bold ${isVerified ? "text-[#10b981] dark:text-green-400" : "text-[#9ca1af] dark:text-gray-400"}`}>
|
||||
{verificationStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{auditReportUrl && (
|
||||
<Button
|
||||
as="a"
|
||||
href={auditReportUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="success"
|
||||
startContent={<ExternalLink size={14} />}
|
||||
>
|
||||
{t("custody.viewReports")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Total Value */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-xl">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">{t("custody.totalValue")}</span>
|
||||
<span className="text-body-default font-bold text-text-primary dark:text-white">
|
||||
{custodyValueUSD > 0 ? formatUSD(custodyValueUSD) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 桌面端:表格布局 ── */}
|
||||
<div className="hidden md:block">
|
||||
{/* Table Header Row */}
|
||||
<div className="grid grid-cols-6 gap-4 pb-4 border-b border-border-gray dark:border-gray-700">
|
||||
<div className="text-caption-tiny font-bold uppercase tracking-wider text-[#9ca1af] dark:text-gray-400 text-center">{t("custody.custodian")}</div>
|
||||
<div className="text-caption-tiny font-bold uppercase tracking-wider text-[#9ca1af] dark:text-gray-400 text-center">{t("custody.assetType")}</div>
|
||||
<div className="text-caption-tiny font-bold uppercase tracking-wider text-[#9ca1af] dark:text-gray-400 text-center">{t("custody.maturity")}</div>
|
||||
<div className="text-caption-tiny font-bold uppercase tracking-wider text-[#9ca1af] dark:text-gray-400 text-center">{t("custody.valueUSD")}</div>
|
||||
<div className="text-caption-tiny font-bold uppercase tracking-wider text-[#9ca1af] dark:text-gray-400 text-center">{t("custody.status")}</div>
|
||||
<div className="text-caption-tiny font-bold uppercase tracking-wider text-[#9ca1af] dark:text-gray-400 text-center">{t("custody.viewReports")}</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body Row */}
|
||||
<div className="grid grid-cols-6 gap-4 py-6">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "linear-gradient(135deg, rgba(255, 137, 4, 1) 0%, rgba(245, 73, 0, 1) 100%)" }}
|
||||
>
|
||||
<span className="text-[13.5px] font-bold leading-[19px] text-white tracking-tight">
|
||||
{custody?.custodianName ? getInitials(custody.custodianName) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">{custody?.custodianName ?? "--"}</span>
|
||||
<span className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">{custody?.custodyType ?? "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="text-body-small font-medium text-text-primary dark:text-white">{assetType}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="text-body-small font-medium text-text-primary dark:text-white">{maturityDate}</span>
|
||||
{daysRemaining !== null && (
|
||||
daysRemaining < 0
|
||||
? <span className="text-caption-tiny font-regular text-red-400">{t("custody.expired")}</span>
|
||||
: <span className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">({daysRemaining} {t("custody.days")})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="text-body-small font-medium text-text-primary dark:text-white">
|
||||
{custodyValueUSD > 0 ? formatUSD(custodyValueUSD) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className={`rounded-full px-3 py-1.5 inline-flex items-center gap-2 ${isVerified ? "bg-[#f2fcf7] dark:bg-green-900/20" : "bg-[#f9fafb] dark:bg-gray-700"}`}>
|
||||
{isVerified ? <CheckCircle size={12} className="text-[#10b981] dark:text-green-400" /> : <XCircle size={12} className="text-[#9ca1af] dark:text-gray-400" />}
|
||||
<span className={`text-[10px] font-bold leading-[150%] ${isVerified ? "text-[#10b981] dark:text-green-400" : "text-[#9ca1af] dark:text-gray-400"}`}>
|
||||
{verificationStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
{auditReportUrl && (
|
||||
<Button
|
||||
as="a"
|
||||
href={auditReportUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="success"
|
||||
startContent={<ExternalLink size={14} />}
|
||||
>
|
||||
{t("custody.viewReports")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Footer Row */}
|
||||
<div className="grid grid-cols-6 gap-4 pt-6 border-t border-border-gray dark:border-gray-700">
|
||||
<div className="col-span-3 flex items-center justify-center">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">{t("custody.totalValue")}</span>
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center justify-center">
|
||||
<span className="text-body-default font-bold text-text-primary dark:text-white">
|
||||
{custodyValueUSD > 0 ? formatUSD(custodyValueUSD) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Cards Row */}
|
||||
<div className="flex flex-col md:flex-row gap-6 pt-6">
|
||||
{/* 验证卡片:移动端1列,桌面端3列 */}
|
||||
{verificationLinks.length > 0 && (
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
||||
{verificationLinks.map((link, idx) => (
|
||||
<VerificationCard
|
||||
key={idx}
|
||||
icon={ICONS[idx % ICONS.length]}
|
||||
title={link.linkText}
|
||||
description={link.description || ""}
|
||||
buttonText={t("custody.viewReports")}
|
||||
reportUrl={link.linkUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Independent Verifications:移动端全宽,桌面端固定宽度 */}
|
||||
<div className="w-full md:w-[calc(25%-18px)] md:shrink-0 rounded-2xl border bg-[#f9fafb] dark:bg-gray-700 border-[#e5e7eb] dark:border-gray-600 p-6 flex flex-col">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-body-default font-bold text-text-primary dark:text-white">
|
||||
{t("custody.independentVerifications")}
|
||||
</h3>
|
||||
<p className="text-body-small font-regular text-[#9ca1af] dark:text-gray-400">
|
||||
{t("custody.independentDesc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-6">
|
||||
{attestationReports.length > 0 ? (
|
||||
attestationReports.map((report, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="rounded-2xl border bg-white dark:bg-gray-800 border-[#e5e7eb] dark:border-gray-600 p-4 flex items-center justify-between text-left transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300 dark:hover:border-gray-500"
|
||||
onClick={() => report.reportUrl && window.open(report.reportUrl, "_blank")}
|
||||
disabled={!report.reportUrl}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-body-small font-bold text-text-primary dark:text-white">
|
||||
{report.reportTitle}
|
||||
</span>
|
||||
<span className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">
|
||||
{report.reportDate}
|
||||
</span>
|
||||
</div>
|
||||
<Image src="/components/product/component-123.svg" alt="" width={24} height={24} />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">
|
||||
{t("custody.noReports")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
webapp/components/product/AssetDescriptionCard.tsx
Normal file
23
webapp/components/product/AssetDescriptionCard.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { ProductDetail } from "@/lib/api/fundmarket";
|
||||
|
||||
interface AssetDescriptionCardProps {
|
||||
product: ProductDetail;
|
||||
}
|
||||
|
||||
export default function AssetDescriptionCard({ product }: AssetDescriptionCardProps) {
|
||||
const { t } = useApp();
|
||||
|
||||
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-4 md:min-h-[320px]">
|
||||
<h3 className="text-body-large font-bold text-text-primary dark:text-white">
|
||||
{t("description.title")}
|
||||
</h3>
|
||||
<div className="text-body-default font-regular text-text-primary dark:text-gray-300 leading-relaxed whitespace-pre-line overflow-y-auto max-h-[200px] md:max-h-[260px]">
|
||||
{product.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
webapp/components/product/AssetOverviewCard.tsx
Normal file
214
webapp/components/product/AssetOverviewCard.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { ProductDetail } from "@/lib/api/fundmarket";
|
||||
|
||||
interface OverviewItemProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function OverviewItem({ icon, label, value }: OverviewItemProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between w-full">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-5 h-5 flex-shrink-0">
|
||||
<Image src={icon} alt={label} width={20} height={20} />
|
||||
</div>
|
||||
<span className="text-xs font-medium leading-[150%] text-[#9ca1af] dark:text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold leading-[150%] text-[#111827] dark:text-white pl-6 md:pl-0">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssetOverviewCardProps {
|
||||
product: ProductDetail;
|
||||
vaultInfo?: any[];
|
||||
isVaultLoading?: boolean;
|
||||
vaultTimedOut?: boolean;
|
||||
}
|
||||
|
||||
export default function AssetOverviewCard({
|
||||
product,
|
||||
vaultInfo,
|
||||
isVaultLoading,
|
||||
vaultTimedOut,
|
||||
}: AssetOverviewCardProps) {
|
||||
const { t } = useApp();
|
||||
|
||||
const hasContract = !!product.contractAddress;
|
||||
const vaultReady = !hasContract || vaultInfo !== undefined || vaultTimedOut;
|
||||
const loading = hasContract && isVaultLoading && !vaultTimedOut;
|
||||
|
||||
// nextRedemptionTime: getVaultInfo[8]
|
||||
const nextRedemptionTime = vaultInfo ? Number(vaultInfo[8]) : 0;
|
||||
const maturityDisplay = (() => {
|
||||
if (loading) return '...';
|
||||
if (!nextRedemptionTime) return '--';
|
||||
return new Date(nextRedemptionTime * 1000).toLocaleDateString('en-GB', {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
});
|
||||
})();
|
||||
|
||||
// ytPrice: getVaultInfo[7], 30 decimals
|
||||
const ytPriceRaw: bigint = vaultInfo ? (vaultInfo[7] as bigint) ?? 0n : 0n;
|
||||
const currentPriceDisplay = (() => {
|
||||
if (loading) return '...';
|
||||
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')}`;
|
||||
})();
|
||||
|
||||
// Pool Capacity %: totalSupply[4] / hardCap[5]
|
||||
const totalSupplyRaw: bigint = vaultInfo ? (vaultInfo[4] as bigint) ?? 0n : 0n;
|
||||
const hardCapRaw: bigint = vaultInfo ? (vaultInfo[5] as bigint) ?? 0n : 0n;
|
||||
const livePoolCapPercent = (vaultReady && hardCapRaw > 0n)
|
||||
? Math.min((Number(totalSupplyRaw) / Number(hardCapRaw)) * 100, 100)
|
||||
: null;
|
||||
const displayPoolCapPercent = livePoolCapPercent !== null
|
||||
? livePoolCapPercent
|
||||
: vaultReady ? product.poolCapacityPercent : null;
|
||||
const poolCapDisplay = !vaultReady
|
||||
? '...'
|
||||
: `${(displayPoolCapPercent ?? 0).toFixed(4)}%`;
|
||||
|
||||
// Format USD values
|
||||
const formatUSD = (value: number) => {
|
||||
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
|
||||
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
// Risk badge
|
||||
const getRiskColor = (riskLevel: number) => {
|
||||
switch (riskLevel) {
|
||||
case 1: return { bg: "#e1f8ec", border: "#b8ecd2", color: "#10b981" };
|
||||
case 2: return { bg: "#fffbf5", border: "#ffedd5", color: "#ffb933" };
|
||||
case 3: return { bg: "#fee2e2", border: "#fecaca", color: "#ef4444" };
|
||||
default: return { bg: "#fffbf5", border: "#ffedd5", color: "#ffb933" };
|
||||
}
|
||||
};
|
||||
const riskColors = getRiskColor(product.riskLevel);
|
||||
|
||||
const getRiskBars = () => {
|
||||
const bars = [
|
||||
{ height: 5, active: product.riskLevel >= 1 },
|
||||
{ height: 7, active: product.riskLevel >= 2 },
|
||||
{ height: 11, active: product.riskLevel >= 3 },
|
||||
];
|
||||
return bars.map((bar, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[3px] rounded-sm flex-shrink-0"
|
||||
style={{
|
||||
height: `${bar.height}px`,
|
||||
backgroundColor: bar.active ? riskColors.color : '#d1d5db',
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border border-[#f3f4f6] dark:border-gray-700 flex flex-col rounded-2xl md:rounded-3xl p-4 md:p-8 gap-5 md:gap-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="text-lg font-bold leading-[150%] text-[#111827] dark:text-white">
|
||||
{t("assetOverview.title")}
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-full border flex items-center"
|
||||
style={{
|
||||
backgroundColor: riskColors.bg,
|
||||
borderColor: riskColors.border,
|
||||
padding: '6px 12px',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: riskColors.color, width: '6px', height: '6px' }}
|
||||
/>
|
||||
<span className="text-xs font-semibold leading-4" style={{ color: riskColors.color }}>
|
||||
{product.riskLabel}
|
||||
</span>
|
||||
<div className="flex items-end gap-[2px]">{getRiskBars()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Items - 2 per row */}
|
||||
<div className="flex flex-col w-full" style={{ gap: '16px' }}>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-5 w-full">
|
||||
<OverviewItem
|
||||
icon="/components/product/component-11.svg"
|
||||
label={t("assetOverview.underlyingAssets")}
|
||||
value={product.underlyingAssets}
|
||||
/>
|
||||
<OverviewItem
|
||||
icon="/components/product/component-12.svg"
|
||||
label={t("assetOverview.maturityRange")}
|
||||
value={maturityDisplay}
|
||||
/>
|
||||
<OverviewItem
|
||||
icon="/components/product/component-13.svg"
|
||||
label={t("assetOverview.cap")}
|
||||
value={formatUSD(product.poolCapUsd)}
|
||||
/>
|
||||
<OverviewItem
|
||||
icon="/components/product/component-15.svg"
|
||||
label={t("assetOverview.poolCapacity")}
|
||||
value={poolCapDisplay}
|
||||
/>
|
||||
</div>
|
||||
{/* Progress Bar - full width */}
|
||||
<div
|
||||
className="w-full bg-[#f3f4f6] dark:bg-gray-600 rounded-full overflow-hidden"
|
||||
style={{ height: '10px' }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${displayPoolCapPercent ?? 0}%`,
|
||||
background: 'linear-gradient(90deg, #1447e6 0%, #032bbd 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-full border-t border-[#f3f4f6] dark:border-gray-700" style={{ height: '1px' }} />
|
||||
|
||||
{/* Current Price */}
|
||||
<div
|
||||
className="bg-[#f9fafb] dark:bg-gray-700 border border-[#f3f4f6] dark:border-gray-600 flex flex-col md:flex-row items-center justify-center md:justify-between gap-2 w-full"
|
||||
style={{ borderRadius: '16px', padding: '16px' }}
|
||||
>
|
||||
<div className="flex items-center" style={{ gap: '12px' }}>
|
||||
<div className="w-5 h-6 flex-shrink-0">
|
||||
<Image src="/components/product/component-16.svg" alt="Price" width={20} height={24} />
|
||||
</div>
|
||||
<span
|
||||
className="text-sm font-medium uppercase"
|
||||
style={{ color: '#4b5563', letterSpacing: '0.7px', lineHeight: '20px' }}
|
||||
>
|
||||
{t("assetOverview.currentPrice")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold leading-[140%]">
|
||||
<span className="text-[#111827] dark:text-white">1 {product.tokenSymbol} = </span>
|
||||
<span style={{ color: "#10b981" }}>{currentPriceDisplay}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
webapp/components/product/ContentSection.tsx
Normal file
40
webapp/components/product/ContentSection.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import TabNavigation from "./TabNavigation";
|
||||
import OverviewTab from "./OverviewTab";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { ProductDetail } from "@/lib/api/fundmarket";
|
||||
|
||||
interface ContentSectionProps {
|
||||
product: ProductDetail;
|
||||
}
|
||||
|
||||
export default function ContentSection({ product }: ContentSectionProps) {
|
||||
const { t } = useApp();
|
||||
|
||||
const tabs = [
|
||||
{ id: "overview", label: t("tabs.overview") },
|
||||
{ id: "asset-description", label: t("tabs.assetDescription") },
|
||||
{ id: "performance-analysis", label: t("tabs.performanceAnalysis") },
|
||||
{ id: "asset-custody", label: t("tabs.assetCustody") },
|
||||
];
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
if (tabId !== "overview") {
|
||||
setTimeout(() => {
|
||||
document.getElementById(tabId)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
defaultActiveId="overview"
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
<OverviewTab product={product} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
503
webapp/components/product/MintSwapPanel.tsx
Normal file
503
webapp/components/product/MintSwapPanel.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
154
webapp/components/product/OverviewTab.tsx
Normal file
154
webapp/components/product/OverviewTab.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useAccount, useReadContract } from 'wagmi';
|
||||
import { bscTestnet } from 'wagmi/chains';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTokenBalance } from '@/hooks/useBalance';
|
||||
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
|
||||
import { useApp } from '@/contexts/AppContext';
|
||||
import { fetchContracts } from '@/lib/api/contracts';
|
||||
import { abis } from '@/lib/contracts';
|
||||
import ProductHeader from "./ProductHeader";
|
||||
import StatsCards from "@/components/fundmarket/StatsCards";
|
||||
import AssetOverviewCard from "./AssetOverviewCard";
|
||||
import APYHistoryCard from "./APYHistoryCard";
|
||||
import AssetDescriptionCard from "./AssetDescriptionCard";
|
||||
import MintSwapPanel from "./MintSwapPanel";
|
||||
import ProtocolInformation from "./ProtocolInformation";
|
||||
import PerformanceAnalysis from "./PerformanceAnalysis";
|
||||
import Season1Rewards from "./Season1Rewards";
|
||||
import AssetCustodyVerification from "./AssetCustodyVerification";
|
||||
import { ProductDetail } from "@/lib/api/fundmarket";
|
||||
|
||||
interface OverviewTabProps {
|
||||
product: ProductDetail;
|
||||
}
|
||||
|
||||
const formatUSD = (v: number) =>
|
||||
"$" + v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
export default function OverviewTab({ product }: OverviewTabProps) {
|
||||
const { t } = useApp();
|
||||
const { isConnected, chainId } = useAccount();
|
||||
const ytToken = useTokenBySymbol(product.tokenSymbol);
|
||||
const { formattedBalance: ytBalance } = useTokenBalance(ytToken?.contractAddress, ytToken?.decimals ?? 18);
|
||||
|
||||
// Shared getVaultInfo read — single source of truth for all children
|
||||
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 hasContract = !!factoryAddress && !!product.contractAddress;
|
||||
const { data: vaultInfo, isLoading: isVaultLoading, refetch: refetchVault } = useReadContract({
|
||||
address: factoryAddress,
|
||||
abi: abis.YTAssetFactory as any,
|
||||
functionName: 'getVaultInfo',
|
||||
args: product.contractAddress ? [product.contractAddress as `0x${string}`] : undefined,
|
||||
chainId: product.chainId,
|
||||
query: { enabled: hasContract },
|
||||
});
|
||||
|
||||
// 3s timeout — after this, fall back to DB snapshot values
|
||||
const [vaultTimedOut, setVaultTimedOut] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!hasContract) return;
|
||||
setVaultTimedOut(false);
|
||||
const t = setTimeout(() => setVaultTimedOut(true), 3000);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasContract]);
|
||||
|
||||
const vaultReady = !hasContract || vaultInfo !== undefined || vaultTimedOut;
|
||||
|
||||
// Real-time TVL: getVaultInfo[1] = totalAssets (USDC)
|
||||
const usdcDecimals = product.chainId === 421614 ? 6 : 18;
|
||||
const totalAssetsRaw: bigint = vaultInfo ? ((vaultInfo as any[])[1] as bigint) ?? 0n : 0n;
|
||||
const liveTVL = totalAssetsRaw > 0n ? Number(totalAssetsRaw) / Math.pow(10, usdcDecimals) : null;
|
||||
|
||||
const tvlDisplay = !vaultReady
|
||||
? "..."
|
||||
: liveTVL !== null
|
||||
? formatUSD(liveTVL)
|
||||
: formatUSD(product.tvlUsd);
|
||||
|
||||
const balanceUSD = parseFloat(ytBalance) * (product.currentPrice || 1);
|
||||
const balanceDisplay = !isConnected
|
||||
? "--"
|
||||
: chainId !== bscTestnet.id
|
||||
? "-- (Switch to BSC)"
|
||||
: formatUSD(balanceUSD);
|
||||
|
||||
const volume24hDisplay = product.volume24hUsd > 0 ? formatUSD(product.volume24hUsd) : "--";
|
||||
const volChange = product.volumeChangeVsAvg ?? 0;
|
||||
const volChangeStr = volChange !== 0
|
||||
? `${volChange > 0 ? "↑" : "↓"} ${Math.abs(volChange).toFixed(0)}% vs Avg`
|
||||
: "";
|
||||
|
||||
const stats = [
|
||||
{ label: t("productPage.totalValueLocked"), value: tvlDisplay, change: "", isPositive: true },
|
||||
{ label: t("productPage.volume24h"), value: volume24hDisplay, change: volChangeStr, isPositive: volChange >= 0 },
|
||||
{ label: t("productPage.cumulativeYield"), value: formatUSD(0), change: "", isPositive: true },
|
||||
{ label: t("productPage.yourTotalBalance"), value: balanceDisplay, change: "", isPositive: true },
|
||||
{ label: t("productPage.yourTotalEarning"), value: "--", change: "", isPositive: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8 w-full">
|
||||
|
||||
{/* ① Header + Stats — 始终在最顶部,桌面端跨3列 */}
|
||||
<div className="order-1 md:col-span-3 min-w-0">
|
||||
<div className="bg-white dark:bg-gray-800 border border-[#f3f4f6] dark:border-gray-700 flex flex-col overflow-hidden rounded-2xl md:rounded-3xl p-4 md:p-8 gap-5 md:gap-6">
|
||||
<ProductHeader product={product} />
|
||||
<StatsCards stats={stats} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ② 交易板 + Protocol — 移动端排第2,桌面端在右列 */}
|
||||
<div className="order-2 md:order-3 md:col-span-1 min-w-0 flex flex-col gap-6 md:gap-8">
|
||||
<MintSwapPanel
|
||||
tokenType={product.tokenSymbol}
|
||||
decimals={product.decimals}
|
||||
onVaultRefresh={refetchVault}
|
||||
/>
|
||||
<ProtocolInformation product={product} />
|
||||
</div>
|
||||
|
||||
{/* ③ Asset Overview + APY + Description — 移动端排第3,桌面端在左列 */}
|
||||
<div className="order-3 md:order-2 md:col-span-2 min-w-0 flex flex-col gap-6 md:gap-8">
|
||||
<AssetOverviewCard
|
||||
product={product}
|
||||
vaultInfo={vaultInfo as any[] | undefined}
|
||||
isVaultLoading={isVaultLoading}
|
||||
vaultTimedOut={vaultTimedOut}
|
||||
/>
|
||||
<APYHistoryCard productId={product.id} />
|
||||
<div id="asset-description">
|
||||
<AssetDescriptionCard product={product} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ④ Season 1 Rewards — 全宽 */}
|
||||
<div className="order-4 md:col-span-3 min-w-0">
|
||||
<Season1Rewards />
|
||||
</div>
|
||||
|
||||
{/* ⑤ Performance Analysis (Daily Net Returns) — 全宽 */}
|
||||
<div id="performance-analysis" className="order-5 md:col-span-3 min-w-0">
|
||||
<PerformanceAnalysis productId={product.id} />
|
||||
</div>
|
||||
|
||||
{/* ⑥ Asset Custody & Verification — 全宽 */}
|
||||
<div id="asset-custody" className="order-6 md:col-span-3 min-w-0">
|
||||
<AssetCustodyVerification product={product} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
webapp/components/product/PerformanceAnalysis.tsx
Normal file
188
webapp/components/product/PerformanceAnalysis.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { fetchDailyReturns, DailyReturnPoint } from "@/lib/api/fundmarket";
|
||||
|
||||
interface PerformanceAnalysisProps {
|
||||
productId: number;
|
||||
}
|
||||
|
||||
type DayType = "positive" | "negative" | "neutral" | "current";
|
||||
|
||||
interface CalendarDayProps {
|
||||
day: number | null;
|
||||
value: string;
|
||||
type: DayType;
|
||||
}
|
||||
|
||||
function CalendarDay({ day, value, type }: CalendarDayProps) {
|
||||
if (day === null) return <div className="flex-1" />;
|
||||
|
||||
const typeStyles: Record<DayType, string> = {
|
||||
positive: "bg-[#f2fcf7] dark:bg-green-900/20 border-[#cef3e0] dark:border-green-700/30",
|
||||
negative: "bg-[#fff8f7] dark:bg-red-900/20 border-[#ffdbd5] dark:border-red-700/30",
|
||||
neutral: "bg-[#f9fafb] dark:bg-gray-700 border-[#f3f4f6] dark:border-gray-600",
|
||||
current: "bg-[#111827] dark:bg-[#111827] border-[#111827]",
|
||||
};
|
||||
const dayTextStyle = type === "current" ? "text-[#fcfcfd]" : "text-[#9ca1af] dark:text-gray-400";
|
||||
const valueTextStyle =
|
||||
type === "current" ? "text-[#10b981]" :
|
||||
type === "positive" ? "text-[#10b981] dark:text-green-400" :
|
||||
type === "negative" ? "text-[#dc2626] dark:text-red-400" :
|
||||
"text-[#9ca1af] dark:text-gray-400";
|
||||
|
||||
return (
|
||||
<div className={`rounded border flex flex-col items-center justify-center flex-1 p-1.5 md:p-3 gap-2 md:gap-6 ${typeStyles[type]}`}>
|
||||
<div className="w-full flex items-start">
|
||||
<span className={`text-[10px] font-bold leading-[150%] ${dayTextStyle}`}>{day}</span>
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-end gap-1">
|
||||
<span className={`text-[9px] md:text-body-small font-bold leading-[150%] ${valueTextStyle}`}>{value}</span>
|
||||
{type === "current" && (
|
||||
<span className="text-[10px] font-bold leading-[150%] tracking-[0.01em] text-[#9DA1AE]">Today</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildCalendar(year: number, month: number, dataMap: Map<string, DailyReturnPoint>, today: string) {
|
||||
const firstDay = new Date(year, month - 1, 1).getDay(); // 0=Sun
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
type Cell = { day: number | null; value: string; type: DayType };
|
||||
const cells: Cell[] = [];
|
||||
|
||||
// Leading empty cells
|
||||
for (let i = 0; i < firstDay; i++) cells.push({ day: null, value: "", type: "neutral" });
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
const pt = dataMap.get(dateStr);
|
||||
const isToday = dateStr === today;
|
||||
const isFuture = dateStr > today;
|
||||
|
||||
if (isFuture) {
|
||||
cells.push({ day: d, value: "--", type: "neutral" });
|
||||
} else if (!pt || !pt.hasData) {
|
||||
cells.push({ day: d, value: "--", type: "neutral" });
|
||||
} else {
|
||||
const r = pt.dailyReturn;
|
||||
const label = r === 0 ? "0.00%" : `${r > 0 ? "+" : ""}${r.toFixed(2)}%`;
|
||||
const type: DayType = isToday ? "current" : r > 0 ? "positive" : r < 0 ? "negative" : "neutral";
|
||||
cells.push({ day: d, value: label, type });
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing empty cells to complete the last row
|
||||
while (cells.length % 7 !== 0) cells.push({ day: null, value: "", type: "neutral" });
|
||||
|
||||
// Split into weeks
|
||||
const weeks: Cell[][] = [];
|
||||
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
|
||||
return weeks;
|
||||
}
|
||||
|
||||
export default function PerformanceAnalysis({ productId }: PerformanceAnalysisProps) {
|
||||
const { t } = useApp();
|
||||
const today = new Date();
|
||||
const [year, setYear] = useState(today.getFullYear());
|
||||
const [month, setMonth] = useState(today.getMonth() + 1);
|
||||
const [dataMap, setDataMap] = useState<Map<string, DailyReturnPoint>>(new Map());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetchDailyReturns(productId, year, month).then((pts) => {
|
||||
const m = new Map<string, DailyReturnPoint>();
|
||||
pts.forEach((p) => m.set(p.date, p));
|
||||
setDataMap(m);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [productId, year, month]);
|
||||
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const weekData = buildCalendar(year, month, dataMap, todayStr);
|
||||
|
||||
const monthLabel = new Date(year, month - 1, 1).toLocaleString("en-US", { month: "long", year: "numeric" });
|
||||
|
||||
const prevMonth = () => {
|
||||
if (month === 1) { setYear(y => y - 1); setMonth(12); }
|
||||
else setMonth(m => m - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1;
|
||||
if (isCurrentMonth) return;
|
||||
if (month === 12) { setYear(y => y + 1); setMonth(1); }
|
||||
else setMonth(m => m + 1);
|
||||
};
|
||||
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1;
|
||||
|
||||
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 md:gap-8">
|
||||
{/* Calendar Section */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between w-full">
|
||||
<div className="flex items-center" style={{ gap: "8px" }}>
|
||||
<div className="w-5 h-6 flex-shrink-0">
|
||||
<Image src="/components/product/icon-performance-chart.svg" alt="" width={20} height={24} />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold leading-5 text-[#0f172a] dark:text-white">
|
||||
{t("performance.dailyNetReturns")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: "8px", height: "24px" }}>
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="rounded-lg flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
style={{ width: "24px", height: "24px" }}
|
||||
>
|
||||
<Image src="/components/common/icon-arrow-left.svg" alt="Previous" width={16} height={16} />
|
||||
</button>
|
||||
<span className="text-sm font-bold text-[#0a0a0a] dark:text-white" style={{ letterSpacing: "-0.15px", lineHeight: "20px" }}>
|
||||
{monthLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
disabled={isCurrentMonth}
|
||||
className="rounded-lg flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{ width: "24px", height: "24px" }}
|
||||
>
|
||||
<Image src="/components/common/icon-arrow-right.svg" alt="Next" width={16} height={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-7 gap-1 md:gap-2">
|
||||
{["sun", "mon", "tue", "wed", "thu", "fri", "sat"].map((day) => (
|
||||
<div key={day} className="flex items-center justify-center">
|
||||
<span className="text-[10px] font-bold leading-[150%] text-[#94a3b8] dark:text-gray-400">
|
||||
{t(`performance.weekdays.${day}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-40 flex items-center justify-center text-[#9ca1af] text-sm">Loading...</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{weekData.map((week, wi) => (
|
||||
<div key={wi} className="grid grid-cols-7 gap-1 md:gap-2">
|
||||
{week.map((day, di) => (
|
||||
<CalendarDay key={`${wi}-${di}`} day={day.day} value={day.value} type={day.type} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
webapp/components/product/ProductDetailSkeleton.tsx
Normal file
169
webapp/components/product/ProductDetailSkeleton.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@heroui/react";
|
||||
|
||||
export default function ProductDetailSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8 w-full">
|
||||
|
||||
{/* ① Header + StatsCards — 同一卡片,跨3列 */}
|
||||
<div className="order-1 md:col-span-3 min-w-0">
|
||||
<div className="bg-white dark:bg-gray-800 border border-[#f3f4f6] dark:border-gray-700 rounded-2xl md:rounded-3xl p-4 md:p-8 flex flex-col gap-5 md:gap-6">
|
||||
{/* ProductHeader */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="w-12 h-12 md:w-16 md:h-16 rounded-2xl flex-shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-6 w-28 md:w-32 rounded-lg" />
|
||||
<Skeleton className="h-4 w-40 md:w-56 rounded" />
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Skeleton className="h-5 w-16 md:w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-20 md:w-24 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-full md:w-36 rounded-xl" />
|
||||
</div>
|
||||
{/* StatsCards — 移动端2列,桌面端5列 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="rounded-2xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-5 flex flex-col gap-3">
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
<Skeleton className="h-7 w-16 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ② 交易板 + Protocol — 移动端排第2,桌面端右列 */}
|
||||
<div className="order-2 md:order-3 md:col-span-1 min-w-0 flex flex-col gap-6 md:gap-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-6 flex flex-col gap-4">
|
||||
<Skeleton className="h-10 w-full rounded-xl" />
|
||||
<Skeleton className="h-14 w-full rounded-2xl" />
|
||||
<Skeleton className="h-14 w-full rounded-2xl" />
|
||||
<Skeleton className="h-4 w-full rounded" />
|
||||
<Skeleton className="h-12 w-full rounded-2xl" />
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-6 flex flex-col gap-4">
|
||||
<Skeleton className="h-5 w-36 rounded" />
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ③ Asset Overview + APY + Description — 移动端排第3,桌面端左列 */}
|
||||
<div className="order-3 md:order-2 md:col-span-2 min-w-0 flex flex-col gap-6 md:gap-8">
|
||||
{/* AssetOverviewCard */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-8 flex flex-col gap-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-5 w-28 rounded" />
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-4 w-16 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-px w-full rounded" />
|
||||
<Skeleton className="h-12 w-full rounded-2xl" />
|
||||
</div>
|
||||
{/* APYHistoryCard */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-6 flex flex-col gap-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-7 w-16 rounded-full" />
|
||||
<Skeleton className="h-7 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-[140px] md:h-[160px] w-full rounded-xl" />
|
||||
</div>
|
||||
{/* AssetDescriptionCard */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-6 flex flex-col gap-4">
|
||||
<Skeleton className="h-5 w-32 rounded" />
|
||||
<div className="flex flex-col gap-2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-4 w-full rounded" />
|
||||
))}
|
||||
<Skeleton className="h-4 w-2/3 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ④ Season1Rewards — 全宽 */}
|
||||
<div className="order-4 md:col-span-3 min-w-0">
|
||||
<Skeleton className="h-24 md:h-28 w-full rounded-3xl" />
|
||||
</div>
|
||||
|
||||
{/* ⑤ PerformanceAnalysis — 全宽 */}
|
||||
<div className="order-5 md:col-span-3 min-w-0">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-8 flex flex-col gap-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-5 w-40 rounded" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: 35 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 md:h-10 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ⑥ AssetCustodyVerification — 全宽 */}
|
||||
<div className="order-6 md:col-span-3 min-w-0 flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-6 w-48 rounded" />
|
||||
<Skeleton className="h-4 w-80 rounded" />
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 pb-6 border-b border-[#f3f4f6] dark:border-gray-700">
|
||||
<Skeleton className="h-5 w-40 rounded" />
|
||||
<Skeleton className="h-4 w-48 rounded" />
|
||||
</div>
|
||||
<div className="hidden md:grid grid-cols-5 gap-4 pb-4 border-b border-[#f3f4f6] dark:border-gray-700">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-3 w-20 rounded" />
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:grid grid-cols-5 gap-4 py-4">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-5 w-full rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="bg-white dark:bg-gray-800 rounded-2xl border border-[#f3f4f6] dark:border-gray-700 p-6 flex flex-col gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="w-5 h-5 rounded flex-shrink-0" />
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<Skeleton className="h-4 w-32 rounded" />
|
||||
<Skeleton className="h-3 w-full rounded" />
|
||||
<Skeleton className="h-3 w-3/4 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
webapp/components/product/ProductHeader.tsx
Normal file
162
webapp/components/product/ProductHeader.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
87
webapp/components/product/ProtocolInformation.tsx
Normal file
87
webapp/components/product/ProtocolInformation.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { ProductDetail } from "@/lib/api/fundmarket";
|
||||
|
||||
interface ProtocolLinkProps {
|
||||
label: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
function ProtocolLink({ label, url }: ProtocolLinkProps) {
|
||||
const enabled = !!url;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => enabled && window.open(url, '_blank', 'noopener,noreferrer')}
|
||||
className={`group rounded-xl border border-border-gray dark:border-gray-600 bg-bg-subtle dark:bg-gray-700 px-4 py-3.5 flex items-center justify-between w-full transition-all ${
|
||||
enabled
|
||||
? 'cursor-pointer hover:border-black dark:hover:border-gray-400'
|
||||
: 'cursor-default opacity-40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/components/product/component-17.svg"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className={enabled ? "transition-all group-hover:scale-110 group-hover:brightness-0 dark:group-hover:brightness-0 dark:group-hover:invert" : ""}
|
||||
/>
|
||||
<span className={`text-body-small font-medium text-text-tertiary dark:text-gray-300 ${
|
||||
enabled ? 'transition-all group-hover:font-bold group-hover:text-text-primary dark:group-hover:text-white' : ''
|
||||
}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
src="/components/product/component-18.svg"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className={enabled ? "transition-all group-hover:scale-110 group-hover:brightness-0 dark:group-hover:brightness-0 dark:group-hover:invert" : "opacity-40"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_LINKS = [
|
||||
"Smart Contract",
|
||||
"Compliance",
|
||||
"Proof of Reserves",
|
||||
"Protocol Information",
|
||||
];
|
||||
|
||||
interface ProtocolInformationProps {
|
||||
product: ProductDetail;
|
||||
}
|
||||
|
||||
export default function ProtocolInformation({ product }: ProtocolInformationProps) {
|
||||
const { t } = useApp();
|
||||
|
||||
const links = product.productLinks ?? [];
|
||||
|
||||
// 只显示 display_area 为 protocol 或 both 的链接
|
||||
const protocolLinks = links.filter(
|
||||
(l) => l.displayArea === 'protocol' || l.displayArea === 'both'
|
||||
);
|
||||
|
||||
// 有配置时用配置的,没配置时用默认占位(全部禁用)
|
||||
const items =
|
||||
protocolLinks.length > 0
|
||||
? protocolLinks.map((l) => ({ label: l.linkText, url: l.linkUrl }))
|
||||
: DEFAULT_LINKS.map((label) => ({ label, url: undefined }));
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 px-4 py-4 md:px-6 md:py-8 flex flex-col gap-4 min-h-0 overflow-hidden">
|
||||
<h3 className="text-body-large font-bold text-text-primary dark:text-white">
|
||||
{t("protocol.title")}
|
||||
</h3>
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-y-auto pr-1 pb-1">
|
||||
{items.map((item, index) => (
|
||||
<ProtocolLink key={index} label={item.label} url={item.url} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
webapp/components/product/Season1Rewards.tsx
Normal file
101
webapp/components/product/Season1Rewards.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
|
||||
interface RewardStatProps {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function RewardStat({ label, value }: RewardStatProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className="text-[24px] font-bold leading-[130%] dark:text-white"
|
||||
style={{ color: "#111827", letterSpacing: "-0.005em" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-bold uppercase leading-[150%] text-center dark:text-gray-400"
|
||||
style={{ color: "#9ca1af", letterSpacing: "0.05em" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Season1Rewards() {
|
||||
const { t } = useApp();
|
||||
return (
|
||||
<div
|
||||
className="rounded-3xl border flex flex-col relative overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(50% 50% at 100% 0%, rgba(255, 217, 100, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%), #ffffff",
|
||||
borderColor: "rgba(255, 255, 255, 0.6)",
|
||||
paddingTop: "20px",
|
||||
paddingBottom: "20px",
|
||||
paddingLeft: "24px",
|
||||
paddingRight: "24px",
|
||||
}}
|
||||
>
|
||||
{/* Background Decoration */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ opacity: 0.5, right: "-15px", bottom: "-20px" }}
|
||||
>
|
||||
<Image
|
||||
src="/components/product/component-113.svg"
|
||||
alt=""
|
||||
width={120}
|
||||
height={144}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between relative z-10 gap-4 md:gap-8">
|
||||
{/* Left: Header and Description */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h3
|
||||
className="text-[20px] font-bold leading-[140%] dark:text-white"
|
||||
style={{ color: "#111827" }}
|
||||
>
|
||||
{t("rewards.season1")}
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-full px-2.5 py-1 flex items-center"
|
||||
style={{ backgroundColor: "#111827" }}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-bold leading-4"
|
||||
style={{ color: "#fcfcfd" }}
|
||||
>
|
||||
{t("rewards.live")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className="text-body-small font-medium dark:text-gray-400"
|
||||
style={{ color: "#9ca1af" }}
|
||||
>
|
||||
{t("rewards.earnPoints")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right: Stats */}
|
||||
<div className="flex items-center justify-between md:justify-end md:gap-12">
|
||||
<RewardStat label={t("rewards.yourPoints")} value="-" />
|
||||
<RewardStat label={t("rewards.badgeBoost")} value="-" />
|
||||
<RewardStat label={t("rewards.referrals")} value="-" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
webapp/components/product/TabNavigation.tsx
Normal file
43
webapp/components/product/TabNavigation.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, Tab } from "@heroui/react";
|
||||
|
||||
interface TabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TabNavigationProps {
|
||||
tabs: TabItem[];
|
||||
defaultActiveId?: string;
|
||||
onTabChange?: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export default function TabNavigation({
|
||||
tabs,
|
||||
defaultActiveId,
|
||||
onTabChange,
|
||||
}: TabNavigationProps) {
|
||||
const handleSelectionChange = (key: React.Key) => {
|
||||
onTabChange?.(key.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
selectedKey={defaultActiveId || tabs[0]?.id}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
variant="underlined"
|
||||
classNames={{
|
||||
base: "w-full overflow-x-auto",
|
||||
tabList: "gap-6 md:gap-8 w-max md:w-auto p-0 min-w-full",
|
||||
cursor: "bg-text-primary dark:bg-white",
|
||||
tab: "px-0 h-auto whitespace-nowrap",
|
||||
tabContent: "text-sm font-bold text-text-tertiary dark:text-gray-400 group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab key={tab.id} title={tab.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user