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,239 @@
"use client";
import Image from "next/image";
import { Button, Card, CardHeader, CardBody, CardFooter } from "@heroui/react";
import { buttonStyles } from "@/lib/buttonStyles";
import { useApp } from "@/contexts/AppContext";
const COLOR_KEYS = ["orange", "green", "blue", "purple", "red", "teal", "indigo", "rose", "amber", "cyan"] as const;
type ColorKey = typeof COLOR_KEYS[number];
const COLOR_MAP: Record<ColorKey, { bg: string; text: string; border: string; gradient: string }> = {
orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-300", gradient: "from-orange-500/5 via-orange-300/3 to-white/0" },
green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-300", gradient: "from-green-500/5 via-green-300/3 to-white/0" },
blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-300", gradient: "from-blue-500/5 via-blue-300/3 to-white/0" },
purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-300", gradient: "from-purple-500/5 via-purple-300/3 to-white/0" },
red: { bg: "bg-red-50", text: "text-red-600", border: "border-red-300", gradient: "from-red-500/5 via-red-300/3 to-white/0" },
teal: { bg: "bg-teal-50", text: "text-teal-600", border: "border-teal-300", gradient: "from-teal-500/5 via-teal-300/3 to-white/0" },
indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-300", gradient: "from-indigo-500/5 via-indigo-300/3 to-white/0" },
rose: { bg: "bg-rose-50", text: "text-rose-600", border: "border-rose-300", gradient: "from-rose-500/5 via-rose-300/3 to-white/0" },
amber: { bg: "bg-amber-50", text: "text-amber-600", border: "border-amber-300", gradient: "from-amber-500/5 via-amber-300/3 to-white/0" },
cyan: { bg: "bg-cyan-50", text: "text-cyan-600", border: "border-cyan-300", gradient: "from-cyan-500/5 via-cyan-300/3 to-white/0" },
};
function resolveColor(categoryColor: string, productId: number) {
if ((COLOR_KEYS as readonly string[]).includes(categoryColor)) {
return COLOR_MAP[categoryColor as ColorKey];
}
return COLOR_MAP[COLOR_KEYS[productId % COLOR_KEYS.length]];
}
interface ProductCardProps {
productId: number;
name: string;
category: string;
categoryColor: string;
iconUrl: string;
yieldAPY: string;
poolCap: string;
maturity: string;
risk: string;
riskLevel: 1 | 2 | 3;
lockUp: string;
circulatingSupply: string;
poolCapacityPercent: number;
status?: 'active' | 'full' | 'ended';
onInvest?: () => void;
}
export default function ProductCard({
productId,
name,
category,
categoryColor,
iconUrl,
yieldAPY,
poolCap,
maturity,
risk,
riskLevel,
lockUp,
circulatingSupply,
poolCapacityPercent,
status,
onInvest,
}: ProductCardProps) {
const getRiskBars = () => {
const bars = [
{ height: "h-[5px]", active: riskLevel >= 1 },
{ height: "h-[7px]", active: riskLevel >= 2 },
{ height: "h-[11px]", active: riskLevel >= 3 },
];
const activeColor =
riskLevel === 1 ? "bg-green-500" : riskLevel === 2 ? "bg-amber-400" : "bg-red-500";
return bars.map((bar, index) => (
<div key={index} className={`${bar.height} w-[3px] rounded-sm ${bar.active ? activeColor : "bg-gray-400"}`} />
));
};
const colors = resolveColor(categoryColor, productId);
const { t } = useApp();
const statusCfg = status
? status === 'ended'
? { label: t("product.statusEnded"), dot: "bg-gray-400", bg: "bg-gray-100", border: "border-gray-300", text: "text-gray-500" }
: status === 'full'
? { label: t("product.statusFull"), dot: "bg-orange-500", bg: "bg-orange-50", border: "border-orange-200", text: "text-orange-600" }
: { label: t("product.statusActive"), dot: "bg-green-500", bg: "bg-green-50", border: "border-green-200", text: "text-green-600" }
: null;
return (
<Card className={`bg-bg-subtle dark:bg-gray-800 border border-border-gray dark:border-gray-700 shadow-lg h-full relative overflow-hidden bg-gradient-to-br rounded-xl ${colors.gradient}`}>
{/* Product Header */}
<CardHeader className="pb-6 px-6 pt-6">
<div className="flex gap-4 items-center w-full">
{/* Icon Container */}
<div className="bg-white/40 dark:bg-gray-700/40 rounded-2xl border border-white/80 dark:border-gray-600/80 w-16 h-16 flex items-center justify-center shadow-[0_0_0_4px_rgba(255,255,255,0.1)] flex-shrink-0">
<div className="w-12 h-12 relative rounded-3xl border-[0.5px] border-gray-100 dark:border-gray-600 overflow-hidden flex items-center justify-center">
<div className="bg-white dark:bg-gray-800 rounded-full w-12 h-12 absolute left-0 top-0" />
<div className="relative z-10 flex items-center justify-center w-12 h-12">
<Image
src={iconUrl || "/assets/tokens/default.svg"}
alt={name}
width={48}
height={48}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
{/* Title, Category, Status */}
<div className="flex flex-col gap-0 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="text-text-primary dark:text-white text-body-large font-bold font-inter truncate">
{name}
</h3>
{statusCfg && (
<div className={`inline-flex items-center gap-1 flex-shrink-0 rounded-full border px-2 py-0.5 ${statusCfg.bg} ${statusCfg.border}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${statusCfg.dot}`} />
<span className={`text-[10px] font-semibold ${statusCfg.text}`}>{statusCfg.label}</span>
</div>
)}
</div>
{/* Category badge: white bg + colored border, always visible against gradient */}
<div className={`bg-white/80 dark:bg-gray-700/80 rounded-full border ${colors.border} px-2 py-0.5 inline-flex self-start mt-0.5`}>
<span className={`${colors.text} text-caption-tiny font-medium font-inter`}>
{category}
</span>
</div>
</div>
</div>
</CardHeader>
<CardBody className="py-0 px-6">
{/* Yield APY & Pool Cap */}
<div className="border-b border-gray-50 dark:border-gray-700 pb-6 flex">
<div className="flex-1 flex flex-col">
<span className="text-gray-600 dark:text-gray-400 text-caption-tiny font-bold font-inter">
{t("productPage.yieldAPY")}
</span>
<span className="text-text-primary dark:text-white text-heading-h3 font-extrabold font-inter">
{yieldAPY}
</span>
</div>
<div className="flex-1 flex flex-col items-end">
<span className="text-gray-600 dark:text-gray-400 text-caption-tiny font-bold font-inter text-right">
{t("productPage.poolCap")}
</span>
<span className="text-green-500 dark:text-green-400 text-heading-h3 font-extrabold font-inter text-right">
{poolCap}
</span>
</div>
</div>
{/* Details Section */}
<div className="pt-6 flex flex-col gap-4">
{/* Maturity & Risk */}
<div className="flex gap-4">
<div className="flex-1 flex flex-col">
<span className="text-text-tertiary dark:text-gray-400 text-[10px] font-medium leading-[150%] tracking-[0.01em] font-inter">
{t("productPage.maturity")}
</span>
<span className="text-gray-600 dark:text-gray-300 text-body-small font-bold font-inter">
{maturity || "--"}
</span>
</div>
<div className="flex-1 flex flex-col items-end">
<span className="text-text-tertiary dark:text-gray-400 text-[10px] font-medium leading-[150%] tracking-[0.01em] font-inter text-right">
{t("productPage.risk")}
</span>
<div className="flex gap-1 items-center">
<span className="text-gray-600 dark:text-gray-300 text-body-small font-bold font-inter">
{risk || "--"}
</span>
<div className="flex gap-0.5 items-end">{getRiskBars()}</div>
</div>
</div>
</div>
{/* Lock-Up & Circulating Supply */}
<div className="flex gap-4">
<div className="flex-1 flex flex-col">
<span className="text-text-tertiary dark:text-gray-400 text-[10px] font-medium leading-[150%] tracking-[0.01em] font-inter">
{t("productPage.lockUp")}
</span>
<span className="text-gray-600 dark:text-gray-300 text-body-small font-bold font-inter">
{lockUp || "--"}
</span>
</div>
<div className="flex-1 flex flex-col items-end">
<span className="text-text-tertiary dark:text-gray-400 text-[10px] font-medium leading-[150%] tracking-[0.01em] font-inter text-right">
{t("productPage.circulatingSupply")}
</span>
<span className="text-gray-600 dark:text-gray-300 text-body-small font-bold font-inter text-right">
{circulatingSupply || "--"}
</span>
</div>
</div>
</div>
</CardBody>
{/* Pool Capacity & Invest Button */}
<CardFooter className="pt-8 pb-6 px-6 flex-col gap-4">
<div className="flex flex-col gap-2 w-full">
<div className="flex justify-between">
<span className="text-text-tertiary dark:text-gray-400 text-caption-tiny font-bold font-inter">
{t("productPage.poolCapacity")}
</span>
<span className="text-text-primary dark:text-white text-body-small font-bold font-inter">
{poolCapacityPercent.toFixed(4)}% {t("productPage.filled")}
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700 rounded-full h-2 relative overflow-hidden">
<div
className="absolute left-0 top-0 bottom-0 rounded-full shadow-[0_0_15px_0_rgba(15,23,42,0.2)]"
style={{
background: "linear-gradient(90deg, rgba(30, 41, 59, 1) 0%, rgba(51, 65, 85, 1) 50%, rgba(15, 23, 42, 1) 100%)",
width: `${poolCapacityPercent}%`,
}}
/>
</div>
</div>
<Button
color="default"
variant="solid"
onPress={status === 'full' && lockUp !== 'Matured' ? undefined : onInvest}
isDisabled={status === 'full' && lockUp !== 'Matured'}
className={
status === 'full' && lockUp !== 'Matured'
? `${buttonStyles({ intent: "theme" })} !bg-[#E5E7EB] !text-[#9CA1AF] cursor-not-allowed`
: buttonStyles({ intent: "theme" })
}
>
{status === 'full' && lockUp !== 'Matured' ? t("productPage.soldOut") : t("productPage.invest")}
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import Image from "next/image";
import { Button, Card, CardBody } from "@heroui/react";
import { buttonStyles } from "@/lib/buttonStyles";
import { useApp } from "@/contexts/AppContext";
const COLOR_KEYS = ["orange", "green", "blue", "purple", "red", "teal", "indigo", "rose", "amber", "cyan"] as const;
type ColorKey = typeof COLOR_KEYS[number];
const COLOR_MAP: Record<ColorKey, { iconBg: string; text: string; border: string }> = {
orange: { iconBg: "bg-orange-50 dark:bg-orange-900/20", text: "text-orange-600", border: "border-orange-300" },
green: { iconBg: "bg-green-50 dark:bg-green-900/20", text: "text-green-600", border: "border-green-300" },
blue: { iconBg: "bg-blue-50 dark:bg-blue-900/20", text: "text-blue-600", border: "border-blue-300" },
purple: { iconBg: "bg-purple-50 dark:bg-purple-900/20", text: "text-purple-600", border: "border-purple-300" },
red: { iconBg: "bg-red-50 dark:bg-red-900/20", text: "text-red-600", border: "border-red-300" },
teal: { iconBg: "bg-teal-50 dark:bg-teal-900/20", text: "text-teal-600", border: "border-teal-300" },
indigo: { iconBg: "bg-indigo-50 dark:bg-indigo-900/20", text: "text-indigo-600", border: "border-indigo-300" },
rose: { iconBg: "bg-rose-50 dark:bg-rose-900/20", text: "text-rose-600", border: "border-rose-300" },
amber: { iconBg: "bg-amber-50 dark:bg-amber-900/20", text: "text-amber-600", border: "border-amber-300" },
cyan: { iconBg: "bg-cyan-50 dark:bg-cyan-900/20", text: "text-cyan-600", border: "border-cyan-300" },
};
function resolveColor(categoryColor: string, productId: number) {
if ((COLOR_KEYS as readonly string[]).includes(categoryColor)) {
return COLOR_MAP[categoryColor as ColorKey];
}
return COLOR_MAP[COLOR_KEYS[productId % COLOR_KEYS.length]];
}
interface ProductCardListProps {
productId: number;
name: string;
category: string;
categoryColor: string;
iconUrl: string;
poolCap: string;
lockUp: string;
poolCapacityPercent: number;
status?: 'active' | 'full' | 'ended';
onInvest?: () => void;
}
export default function ProductCardList({
productId,
name,
category,
categoryColor,
iconUrl,
poolCap,
lockUp,
poolCapacityPercent,
status,
onInvest,
}: ProductCardListProps) {
const { t } = useApp();
const colors = resolveColor(categoryColor, productId);
const statusCfg = status
? status === 'ended'
? { label: t("product.statusEnded"), dot: "bg-gray-400", bg: "bg-gray-100", border: "border-gray-300", text: "text-gray-500" }
: status === 'full'
? { label: t("product.statusFull"), dot: "bg-orange-500", bg: "bg-orange-50", border: "border-orange-200", text: "text-orange-600" }
: { label: t("product.statusActive"), dot: "bg-green-500", bg: "bg-green-50", border: "border-green-200", text: "text-green-600" }
: null;
const isSoldOut = status === 'full' && lockUp !== 'Matured';
return (
<Card className="bg-bg-subtle dark:bg-gray-800 border border-border-gray dark:border-gray-700 rounded-xl">
<CardBody className="py-4 px-4 md:py-6 md:px-8">
{/* ── 桌面端横向布局 ── */}
<div className="hidden md:flex flex-row items-center justify-between">
{/* Icon + Title */}
<div className="flex flex-row gap-4 items-center w-[280px] flex-shrink-0">
<div className={`${colors.iconBg} rounded-md border-[0.75px] border-black/10 dark:border-white/10 w-10 h-[30px] relative shadow-[inset_0_1.5px_3px_0_rgba(0,0,0,0.05)] overflow-hidden flex items-center justify-center flex-shrink-0`}>
<Image
src={iconUrl || "/assets/tokens/default.svg"}
alt={name}
width={43}
height={30}
className="w-[107.69%] h-full object-cover"
/>
</div>
<div className="flex flex-col gap-0.5 items-start min-w-0 flex-1">
<div className="text-text-tertiary dark:text-gray-400 text-caption-tiny font-normal font-inter">
{category}
</div>
<div className="flex items-center gap-2 w-full">
<span className="text-text-primary dark:text-white text-body-default font-bold font-inter truncate">
{name}
</span>
{statusCfg && (
<div className={`inline-flex items-center gap-1 flex-shrink-0 rounded-full border px-2 py-0.5 ${statusCfg.bg} ${statusCfg.border}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${statusCfg.dot}`} />
<span className={`text-[10px] font-semibold ${statusCfg.text}`}>{statusCfg.label}</span>
</div>
)}
</div>
</div>
</div>
{/* Stats */}
<div className="flex flex-row items-center justify-between flex-1 px-12">
<div className="flex flex-col gap-1 items-start w-[120px] flex-shrink-0">
<div className="text-text-tertiary dark:text-gray-400 text-caption-tiny font-normal font-inter">
{t("productPage.lockUp")}
</div>
<div className="text-text-primary dark:text-white text-body-small font-bold font-inter">
{lockUp || "--"}
</div>
</div>
<div className="flex flex-col gap-1 items-start w-[100px] flex-shrink-0">
<div className="text-text-tertiary dark:text-gray-400 text-caption-tiny font-normal font-inter">
{t("productPage.poolCap")}
</div>
<div className="text-green-500 dark:text-green-400 text-body-small font-bold font-inter">
{poolCap || "--"}
</div>
</div>
<div className="flex flex-col gap-1 items-start w-[260px] flex-shrink-0">
<div className="text-text-tertiary dark:text-gray-400 text-caption-tiny font-normal font-inter">
{t("productPage.poolCapacity")}
</div>
<div className="flex flex-row gap-3 items-center">
<div className="bg-gray-100 dark:bg-gray-700 rounded-full w-24 h-2 relative overflow-hidden flex-shrink-0">
<div
className="rounded-full h-2"
style={{
background: "linear-gradient(90deg, rgba(20, 71, 230, 1) 0%, rgba(3, 43, 189, 1) 100%)",
width: `${poolCapacityPercent}%`,
}}
/>
</div>
<div className="text-text-primary dark:text-white text-body-small font-bold font-inter whitespace-nowrap">
{poolCapacityPercent.toFixed(4)}% {t("productPage.filled")}
</div>
</div>
</div>
</div>
{/* Invest Button */}
<div className="flex-shrink-0">
<Button
color="default"
variant="solid"
onPress={isSoldOut ? undefined : onInvest}
isDisabled={isSoldOut}
className={isSoldOut ? `${buttonStyles({ intent: "theme" })} !bg-[#E5E7EB] !text-[#9CA1AF] cursor-not-allowed` : buttonStyles({ intent: "theme" })}
>
{isSoldOut ? t("productPage.soldOut") : t("productPage.invest")}
</Button>
</div>
</div>
{/* ── 移动端竖向布局 ── */}
<div className="flex md:hidden flex-col gap-3">
{/* 第一行Icon + 名称 + 状态 */}
<div className="flex items-center gap-3">
<div className={`${colors.iconBg} rounded-md border-[0.75px] border-black/10 dark:border-white/10 w-10 h-[30px] relative shadow-[inset_0_1.5px_3px_0_rgba(0,0,0,0.05)] overflow-hidden flex items-center justify-center flex-shrink-0`}>
<Image
src={iconUrl || "/assets/tokens/default.svg"}
alt={name}
width={43}
height={30}
className="w-[107.69%] h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-text-tertiary dark:text-gray-400 text-[11px] font-normal">{category}</div>
<div className="text-text-primary dark:text-white text-sm font-bold truncate">{name}</div>
</div>
{statusCfg && (
<div className={`inline-flex items-center gap-1 flex-shrink-0 rounded-full border px-2 py-0.5 ${statusCfg.bg} ${statusCfg.border}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${statusCfg.dot}`} />
<span className={`text-[10px] font-semibold ${statusCfg.text}`}>{statusCfg.label}</span>
</div>
)}
</div>
{/* 第二行Lock-up + Pool Cap */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-white dark:bg-gray-700/50 rounded-lg px-3 py-2">
<div className="text-text-tertiary dark:text-gray-400 text-[11px] mb-0.5">{t("productPage.lockUp")}</div>
<div className="text-text-primary dark:text-white text-sm font-bold">{lockUp || "--"}</div>
</div>
<div className="bg-white dark:bg-gray-700/50 rounded-lg px-3 py-2">
<div className="text-text-tertiary dark:text-gray-400 text-[11px] mb-0.5">{t("productPage.poolCap")}</div>
<div className="text-green-500 dark:text-green-400 text-sm font-bold">{poolCap || "--"}</div>
</div>
</div>
{/* 第三行Pool Capacity 进度条 */}
<div className="bg-white dark:bg-gray-700/50 rounded-lg px-3 py-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-text-tertiary dark:text-gray-400 text-[11px]">{t("productPage.poolCapacity")}</div>
<div className="text-text-primary dark:text-white text-[11px] font-bold">
{poolCapacityPercent.toFixed(2)}% {t("productPage.filled")}
</div>
</div>
<div className="bg-gray-100 dark:bg-gray-600 rounded-full w-full h-1.5 overflow-hidden">
<div
className="rounded-full h-1.5"
style={{
background: "linear-gradient(90deg, rgba(20, 71, 230, 1) 0%, rgba(3, 43, 189, 1) 100%)",
width: `${poolCapacityPercent}%`,
}}
/>
</div>
</div>
{/* 第四行:按钮 */}
<Button
color="default"
variant="solid"
onPress={isSoldOut ? undefined : onInvest}
isDisabled={isSoldOut}
className={`w-full ${isSoldOut ? `${buttonStyles({ intent: "theme" })} !bg-[#E5E7EB] !text-[#9CA1AF] cursor-not-allowed` : buttonStyles({ intent: "theme" })}`}
>
{isSoldOut ? t("productPage.soldOut") : t("productPage.invest")}
</Button>
</div>
</CardBody>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { Card, CardBody, Skeleton } from "@heroui/react";
export default function ProductCardListSkeleton() {
return (
<Card className="bg-bg-subtle dark:bg-gray-800 border border-border-gray dark:border-gray-700 rounded-xl">
<CardBody className="py-6 px-8">
<div className="flex flex-row items-center justify-between">
{/* Icon and Title - Fixed width */}
<div className="flex flex-row gap-4 items-center justify-start w-[280px] flex-shrink-0">
<Skeleton className="rounded-md w-10 h-[30px] flex-shrink-0" />
<div className="flex flex-col gap-2 items-start min-w-0 flex-1">
<Skeleton className="h-3 w-16 rounded" />
<Skeleton className="h-4 w-32 rounded" />
</div>
</div>
{/* Middle section with stats */}
<div className="flex flex-row items-center justify-between flex-1 px-12">
{/* Lock-up */}
<div className="flex flex-col gap-2 items-start w-[120px] flex-shrink-0">
<Skeleton className="h-3 w-12 rounded" />
<Skeleton className="h-4 w-20 rounded" />
</div>
{/* Pool Cap */}
<div className="flex flex-col gap-2 items-start w-[100px] flex-shrink-0">
<Skeleton className="h-3 w-16 rounded" />
<Skeleton className="h-4 w-16 rounded" />
</div>
{/* Pool Capacity */}
<div className="flex flex-col gap-2 items-start w-[260px] flex-shrink-0">
<Skeleton className="h-3 w-20 rounded" />
<div className="flex flex-row gap-3 items-center">
<Skeleton className="rounded-full w-24 h-2" />
<Skeleton className="h-4 w-16 rounded" />
</div>
</div>
</div>
{/* Invest Button */}
<div className="flex-shrink-0">
<Skeleton className="h-10 w-24 rounded-lg" />
</div>
</div>
</CardBody>
</Card>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { Card, CardHeader, CardBody, CardFooter, Skeleton } from "@heroui/react";
export default function ProductCardSkeleton() {
return (
<Card className="bg-bg-subtle dark:bg-gray-800 border border-border-gray dark:border-gray-700 shadow-lg h-full rounded-xl">
{/* Product Header */}
<CardHeader className="pb-6 px-6 pt-6">
<div className="flex gap-4 items-center w-full">
{/* Icon Skeleton */}
<Skeleton className="rounded-2xl w-16 h-16 flex-shrink-0" />
{/* Title and Category Skeleton */}
<div className="flex flex-col gap-2 flex-1">
<Skeleton className="h-6 w-32 rounded-lg" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
</div>
</CardHeader>
<CardBody className="py-0 px-6">
{/* Yield APY & Pool Cap Skeleton */}
<div className="border-b border-gray-50 dark:border-gray-700 pb-6 flex">
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-3 w-16 rounded" />
<Skeleton className="h-8 w-20 rounded" />
</div>
<div className="flex-1 flex flex-col items-end gap-2">
<Skeleton className="h-3 w-16 rounded" />
<Skeleton className="h-8 w-24 rounded" />
</div>
</div>
{/* Details Section Skeleton */}
<div className="pt-6 flex flex-col gap-4">
{/* Maturity & Risk */}
<div className="flex gap-4">
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-3 w-16 rounded" />
<Skeleton className="h-5 w-24 rounded" />
</div>
<div className="flex-1 flex flex-col items-end gap-2">
<Skeleton className="h-3 w-10 rounded" />
<Skeleton className="h-5 w-20 rounded" />
</div>
</div>
{/* Lock-Up & Circulating Supply */}
<div className="flex gap-4">
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-3 w-14 rounded" />
<Skeleton className="h-5 w-20 rounded" />
</div>
<div className="flex-1 flex flex-col items-end gap-2">
<Skeleton className="h-3 w-24 rounded" />
<Skeleton className="h-5 w-16 rounded" />
</div>
</div>
</div>
</CardBody>
{/* Pool Capacity & Invest Button Skeleton */}
<CardFooter className="pt-8 pb-6 px-6 flex-col gap-4">
<div className="flex flex-col gap-2 w-full">
<div className="flex justify-between">
<Skeleton className="h-3 w-24 rounded" />
<Skeleton className="h-4 w-16 rounded" />
</div>
<Skeleton className="h-2 w-full rounded-full" />
</div>
<Skeleton className="h-10 w-full rounded-lg" />
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
interface StatData {
label: string;
value: string;
change: string;
isPositive: boolean;
}
interface StatsCardsProps {
stats?: StatData[];
cols?: 4 | 5;
}
const COLS_CLASS: Record<number, string> = {
4: "grid grid-cols-2 md:grid-cols-4 gap-4",
5: "grid grid-cols-2 md:grid-cols-5 gap-4",
};
export default function StatsCards({ stats = [], cols }: StatsCardsProps) {
if (!stats || stats.length === 0) return null;
const gridClass = COLS_CLASS[cols ?? stats.length] ?? COLS_CLASS[4];
return (
<div className={gridClass}>
{stats.map((stat, index) => (
<div
key={index}
className="flex flex-col gap-2 items-start justify-start
bg-[#f9fafb] dark:bg-gray-800
border border-[#f3f4f6] dark:border-gray-700
rounded-2xl p-4"
>
{/* Label */}
<div
className="text-[#9ca1af] dark:text-gray-400"
style={{ fontFamily: 'Inter, sans-serif', fontSize: 12, fontWeight: 700, lineHeight: '150%', letterSpacing: '0.01em' }}
>
{stat.label}
</div>
{/* Value */}
<div
className="text-[#111827] dark:text-white truncate w-full"
style={{ fontFamily: 'Inter, sans-serif', fontSize: 24, fontWeight: 700, lineHeight: '130%', letterSpacing: '-0.005em' }}
title={stat.value}
>
{stat.value}
</div>
{/* Change indicator */}
{stat.change ? (
<div
style={{
fontFamily: 'Inter, sans-serif',
fontSize: 12,
fontWeight: 500,
lineHeight: '150%',
letterSpacing: '0.01em',
color: stat.isPositive ? '#10b981' : '#ef4444',
}}
>
{stat.change}
</div>
) : (
<div style={{ height: 18 }} />
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { Card, CardBody, Skeleton } from "@heroui/react";
export default function StatsCardsSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[1, 2, 3, 4].map((index) => (
<Card
key={index}
className="bg-bg-surface dark:bg-gray-800 border border-border-gray dark:border-gray-700 rounded-[2rem]"
>
<CardBody className="py-6 px-6">
<div className="flex flex-col gap-3">
{/* Label Skeleton */}
<Skeleton className="h-4 w-32 rounded" />
{/* Value Skeleton */}
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-28 rounded" />
</div>
{/* Change Badge Skeleton */}
<Skeleton className="h-5 w-16 rounded-full" />
</div>
</CardBody>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { Button } from "@heroui/react";
import Image from "next/image";
type ViewMode = "grid" | "list";
interface ViewToggleProps {
value: ViewMode;
onChange: (mode: ViewMode) => void;
}
export default function ViewToggle({ value, onChange }: ViewToggleProps) {
return (
<div className="bg-gray-200 dark:bg-gray-700 rounded-lg p-1 flex gap-0">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onChange("list")}
className={`w-8 h-8 min-w-8 rounded-lg transition-all ${
value === "list"
? "bg-white dark:bg-gray-600 shadow-sm"
: "bg-transparent hover:bg-gray-300 dark:hover:bg-gray-600"
}`}
style={{ padding: 0 }}
>
<Image
src="/icons/ui/edit-list-unordered0.svg"
alt="List view"
width={24}
height={24}
/>
</Button>
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => onChange("grid")}
className={`w-8 h-8 min-w-8 rounded-lg transition-all ${
value === "grid"
? "bg-white dark:bg-gray-600 shadow-sm"
: "bg-transparent hover:bg-gray-300 dark:hover:bg-gray-600"
}`}
style={{ padding: 0 }}
>
<Image
src="/icons/ui/menu-more-grid-small0.svg"
alt="Grid view"
width={24}
height={24}
/>
</Button>
</div>
);
}