包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
350 lines
17 KiB
TypeScript
350 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|