Files
assetx/webapp/components/product/AssetCustodyVerification.tsx

350 lines
17 KiB
TypeScript
Raw Permalink Normal View History

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