init: 初始化 AssetX 项目仓库

包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
This commit is contained in:
2026-03-27 11:26:43 +00:00
commit 2ee4553b71
634 changed files with 988255 additions and 0 deletions

View 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% paddingAPY 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}