Files
assetx/webapp/components/product/AssetCustodyVerification.tsx
default 2ee4553b71 init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
2026-03-27 11:26:43 +00:00

350 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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