init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
58
webapp/app/alp/page.tsx
Normal file
58
webapp/app/alp/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import ALPStatsCards from "@/components/alp/ALPStatsCards";
|
||||
import PriceHistoryCard from "@/components/alp/PriceHistoryCard";
|
||||
import PoolDepositPanel from "@/components/alp/PoolDepositPanel";
|
||||
import LiquidityAllocationTable from "@/components/alp/LiquidityAllocationTable";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
|
||||
export default function ALPPage() {
|
||||
const { t } = useApp();
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/" },
|
||||
{ label: t("nav.alp") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 md:px-8 py-4 md:py-8 bg-[#F3F4F6] dark:bg-gray-900">
|
||||
{/* Page Title and Stats Cards Section */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-3xl border border-border-gray dark:border-gray-700 px-4 md:px-6 py-6 md:py-8 mb-4 md:mb-8">
|
||||
<div className="mb-4 md:mb-6">
|
||||
<h1 className="text-2xl md:text-[32px] font-bold leading-[130%] tracking-[-0.01em] text-text-primary dark:text-white">
|
||||
{t("alp.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div>
|
||||
<ALPStatsCards />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Price History and Pool Deposit Panel */}
|
||||
<div className="flex flex-col md:grid md:grid-cols-[minmax(0,2fr)_minmax(360px,1fr)] gap-4 md:gap-8 mb-4 md:mb-8">
|
||||
<PriceHistoryCard />
|
||||
<PoolDepositPanel onSuccess={() => setRefreshTrigger(n => n + 1)} />
|
||||
</div>
|
||||
|
||||
{/* Liquidity Allocation Table */}
|
||||
<div>
|
||||
<LiquidityAllocationTable refreshTrigger={refreshTrigger} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
webapp/app/ecosystem/page.tsx
Normal file
75
webapp/app/ecosystem/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function EcosystemPage() {
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/" },
|
||||
{ label: "Ecosystem" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-subtle dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3 md:py-6">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 md:px-8 py-4 md:py-8 bg-[#F3F4F6] dark:bg-gray-900 flex items-center justify-center">
|
||||
{/* Coming Soon Card */}
|
||||
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6 md:p-16 max-w-2xl w-full">
|
||||
<div className="flex flex-col items-center gap-8 text-center">
|
||||
{/* Icon */}
|
||||
<div className="relative w-24 h-24 opacity-40">
|
||||
<Image
|
||||
src="/icons/navigation/icon-ecosystem.svg"
|
||||
alt="Ecosystem"
|
||||
fill
|
||||
className="object-contain brightness-[0.45] dark:brightness-100 dark:invert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white">
|
||||
Ecosystem Coming Soon
|
||||
</h1>
|
||||
<p className="text-body-default text-text-secondary dark:text-gray-400 leading-relaxed max-w-md">
|
||||
We're building something exciting! The Ecosystem page will be available soon with powerful features and integrations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features Preview */}
|
||||
<div className="flex flex-col gap-3 w-full mt-4">
|
||||
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl p-4 flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-[#10b981]" />
|
||||
<span className="text-body-small text-text-primary dark:text-white">
|
||||
Protocol Integrations
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl p-4 flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-[#10b981]" />
|
||||
<span className="text-body-small text-text-primary dark:text-white">
|
||||
Partner Networks
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl p-4 flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-[#10b981]" />
|
||||
<span className="text-body-small text-text-primary dark:text-white">
|
||||
Cross-chain Bridges
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<p className="text-caption-small text-text-tertiary dark:text-gray-500 mt-4">
|
||||
Stay tuned for updates 🚀
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
webapp/app/error.tsx
Normal file
30
webapp/app/error.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Application error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Something went wrong</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
An unexpected error occurred. Please try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-lg bg-emerald-600 px-6 py-2 text-sm font-medium text-white hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
webapp/app/globals.css
Normal file
104
webapp/app/globals.css
Normal file
@@ -0,0 +1,104 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #f9fafb;
|
||||
--foreground: #111827;
|
||||
--font-inter: 'Inter', sans-serif;
|
||||
|
||||
/* HeroUI Button 默认颜色覆盖 - 日间模式 */
|
||||
--btn-default-bg: #272E40;
|
||||
--btn-default-text: #ffffff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #111827;
|
||||
--foreground: #f9fafb;
|
||||
|
||||
/* HeroUI Button 默认颜色覆盖 - 夜间模式 */
|
||||
--btn-default-bg: #111827;
|
||||
--btn-default-text: #ffffff;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-inter);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
menu,
|
||||
ol,
|
||||
ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInCard {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保动画延迟时初始状态为透明 */
|
||||
.animate-fade-in {
|
||||
animation-fill-mode: both;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 平滑的表格行动画 */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
41
webapp/app/layout.tsx
Normal file
41
webapp/app/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import ResourcePreload from "@/components/ResourcePreload";
|
||||
|
||||
// 使用系统字体,避免 Google Fonts 连接问题
|
||||
const inter = {
|
||||
variable: "--font-inter",
|
||||
className: "",
|
||||
};
|
||||
|
||||
const jetbrainsMono = {
|
||||
variable: "--font-inter",
|
||||
className: "",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AssetX Dashboard",
|
||||
description: "DeFi Asset Management Platform",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable} ${inter.className} overflow-x-hidden`}>
|
||||
<ResourcePreload />
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
53
webapp/app/lending/page.tsx
Normal file
53
webapp/app/lending/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import LendingHeader from "@/components/lending/LendingHeader";
|
||||
import LendingStats from "@/components/lending/LendingStats";
|
||||
import LendingPlaceholder from "@/components/lending/LendingPlaceholder";
|
||||
import BorrowMarket from "@/components/lending/BorrowMarket";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
|
||||
export default function LendingPage() {
|
||||
const { t } = useApp();
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/" },
|
||||
{ label: t("nav.lending") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 md:px-8 py-4 md:py-8 bg-[#F3F4F6] dark:bg-gray-900 flex flex-col gap-4 md:gap-8">
|
||||
|
||||
{/* Market Tab Switcher */}
|
||||
<div className="flex gap-1 bg-bg-surface dark:bg-gray-800 border border-border-gray dark:border-gray-700 rounded-2xl p-1 self-start">
|
||||
<button
|
||||
className="px-5 py-2 rounded-xl text-body-small font-bold bg-foreground text-background shadow-sm"
|
||||
>
|
||||
USDC Market
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
className="px-5 py-2 rounded-xl text-body-small font-bold text-text-tertiary dark:text-gray-600 cursor-not-allowed opacity-50"
|
||||
>
|
||||
USDT Market
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LendingHeader market="USDC" />
|
||||
<div className="flex flex-col md:grid md:gap-8 md:grid-cols-[760fr_362fr] gap-4">
|
||||
<LendingStats />
|
||||
<LendingPlaceholder />
|
||||
</div>
|
||||
<BorrowMarket market="USDC" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
webapp/app/lending/repay/page.tsx
Normal file
94
webapp/app/lending/repay/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import RepayHeader from "@/components/lending/repay/RepayHeader";
|
||||
import RepaySupplyCollateral from "@/components/lending/repay/RepaySupplyCollateral";
|
||||
import RepayBorrowDebt from "@/components/lending/repay/RepayBorrowDebt";
|
||||
import RepayStats from "@/components/lending/repay/RepayStats";
|
||||
import RepayPoolStats from "@/components/lending/repay/RepayPoolStats";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { useTokenList } from "@/hooks/useTokenList";
|
||||
|
||||
function RepayContent() {
|
||||
const { t } = useApp();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { yieldTokens } = useTokenList();
|
||||
const rawToken = searchParams.get('token') || '';
|
||||
const validSymbols = yieldTokens.map(t => t.symbol);
|
||||
const tokenType = validSymbols.includes(rawToken) ? rawToken : (validSymbols[0] ?? 'YT-A');
|
||||
const [collateralVersion, setCollateralVersion] = useState(0);
|
||||
|
||||
const handleBackToLending = () => {
|
||||
router.push("/lending");
|
||||
};
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/" },
|
||||
{ label: t("nav.lending"), href: "/lending" },
|
||||
{ label: t("repay.repay") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 md:px-8 py-4 md:py-8 bg-[#F3F4F6] dark:bg-gray-900 flex flex-col gap-4 md:gap-8">
|
||||
{/* Back to lending link */}
|
||||
<button
|
||||
onClick={handleBackToLending}
|
||||
className="flex items-center gap-2 self-start group"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="group-hover:translate-x-[-2px] transition-transform"
|
||||
>
|
||||
<path
|
||||
d="M10 12L6 8L10 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-text-tertiary dark:text-gray-400"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-body-large font-semibold text-text-tertiary dark:text-gray-400 group-hover:text-text-primary dark:group-hover:text-white transition-colors">
|
||||
{t("repay.backToLending")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<RepayHeader tokenType={tokenType} />
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-8">
|
||||
<RepaySupplyCollateral
|
||||
tokenType={tokenType}
|
||||
onCollateralChanged={() => setCollateralVersion(v => v + 1)}
|
||||
/>
|
||||
<RepayBorrowDebt refreshTrigger={collateralVersion} />
|
||||
<RepayStats />
|
||||
</div>
|
||||
<RepayPoolStats tokenType={tokenType} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RepayPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-[#F3F4F6] dark:bg-gray-900" />}>
|
||||
<RepayContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
131
webapp/app/lending/supply/page.tsx
Normal file
131
webapp/app/lending/supply/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import SupplyContent from "@/components/lending/supply/SupplyContent";
|
||||
import SupplyPanel from "@/components/lending/supply/SupplyPanel";
|
||||
import WithdrawPanel from "@/components/lending/supply/WithdrawPanel";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
|
||||
export default function SupplyPage() {
|
||||
const { t } = useApp();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"supply" | "withdraw">("supply");
|
||||
|
||||
const handleBackToLending = () => {
|
||||
router.push("/lending");
|
||||
};
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/" },
|
||||
{ label: t("nav.lending"), href: "/lending" },
|
||||
{ label: t("supply.supply") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 md:px-8 py-4 md:py-8 bg-[#F3F4F6] dark:bg-gray-900 flex flex-col gap-4 md:gap-8">
|
||||
{/* Back to lending link */}
|
||||
<button
|
||||
onClick={handleBackToLending}
|
||||
className="flex items-center gap-2 self-start group"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="group-hover:translate-x-[-2px] transition-transform"
|
||||
>
|
||||
<path
|
||||
d="M10 12L6 8L10 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-text-tertiary dark:text-gray-400"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-body-large font-semibold text-text-tertiary dark:text-gray-400 group-hover:text-text-primary dark:group-hover:text-white transition-colors">
|
||||
{t("repay.backToLending")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-8 w-full items-start">
|
||||
<div className="w-full md:flex-[2]">
|
||||
<SupplyContent />
|
||||
</div>
|
||||
<div className="w-full md:flex-[1] flex flex-col bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 md:max-w-[460px]">
|
||||
{/* Supply/Withdraw Buttons */}
|
||||
<div className="flex gap-0 px-4 pt-4 relative">
|
||||
{/* Supply Button */}
|
||||
<button
|
||||
onClick={() => setActiveTab("supply")}
|
||||
className={`flex items-center justify-center gap-2 py-4 flex-1 transition-all duration-300 ease-in-out relative z-10 ${
|
||||
activeTab === "supply"
|
||||
? ""
|
||||
: "hover:bg-bg-subtle/50 dark:hover:bg-gray-700/30"
|
||||
}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 3.75V14.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={activeTab === "supply" ? "text-text-primary dark:text-white" : "text-text-tertiary dark:text-gray-400"}/>
|
||||
<path d="M14.25 9L9 14.25L3.75 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={activeTab === "supply" ? "text-text-primary dark:text-white" : "text-text-tertiary dark:text-gray-400"}/>
|
||||
</svg>
|
||||
<span className={`text-body-small font-bold leading-[20px] tracking-[-0.15px] transition-colors duration-300 ${
|
||||
activeTab === "supply" ? "text-text-primary dark:text-white" : "text-text-tertiary dark:text-gray-400"
|
||||
}`}>
|
||||
{t("supply.supply")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Withdraw Button */}
|
||||
<button
|
||||
onClick={() => setActiveTab("withdraw")}
|
||||
className={`flex items-center justify-center gap-2 py-4 flex-1 transition-all duration-300 ease-in-out relative z-10 ${
|
||||
activeTab === "withdraw"
|
||||
? ""
|
||||
: "hover:bg-bg-subtle/50 dark:hover:bg-gray-700/30"
|
||||
}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 14.25V3.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={activeTab === "withdraw" ? "text-text-primary dark:text-white" : "text-text-tertiary dark:text-gray-400"}/>
|
||||
<path d="M3.75 9L9 3.75L14.25 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={activeTab === "withdraw" ? "text-text-primary dark:text-white" : "text-text-tertiary dark:text-gray-400"}/>
|
||||
</svg>
|
||||
<span className={`text-body-small font-bold leading-[20px] tracking-[-0.15px] transition-colors duration-300 ${
|
||||
activeTab === "withdraw" ? "text-text-primary dark:text-white" : "text-text-tertiary dark:text-gray-400"
|
||||
}`}>
|
||||
{t("supply.withdraw")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Sliding indicator line */}
|
||||
<div
|
||||
className={`absolute bottom-0 h-[3px] bg-text-primary dark:bg-white transition-all duration-300 ease-in-out ${
|
||||
activeTab === "supply" ? "left-0 w-1/2" : "left-1/2 w-1/2"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider Line */}
|
||||
<div className="h-px bg-border-gray dark:bg-gray-700 mx-4" />
|
||||
|
||||
{/* Panel Content */}
|
||||
<div className="flex-1">
|
||||
{activeTab === "supply" ? <SupplyPanel /> : <WithdrawPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
421
webapp/app/market/page.tsx
Normal file
421
webapp/app/market/page.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useReadContracts, useAccount } from "wagmi";
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import PageTitle from "@/components/layout/PageTitle";
|
||||
import SectionHeader from "@/components/layout/SectionHeader";
|
||||
import ViewToggle from "@/components/fundmarket/ViewToggle";
|
||||
import StatsCards from "@/components/fundmarket/StatsCards";
|
||||
import StatsCardsSkeleton from "@/components/fundmarket/StatsCardsSkeleton";
|
||||
import ProductCard from "@/components/fundmarket/ProductCard";
|
||||
import ProductCardSkeleton from "@/components/fundmarket/ProductCardSkeleton";
|
||||
import ProductCardList from "@/components/fundmarket/ProductCardList";
|
||||
import ProductCardListSkeleton from "@/components/fundmarket/ProductCardListSkeleton";
|
||||
import { fetchProducts, fetchStats } from "@/lib/api/fundmarket";
|
||||
import { fetchContracts } from "@/lib/api/contracts";
|
||||
import { abis } from "@/lib/contracts";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
|
||||
const erc20BalanceOfAbi = [
|
||||
{
|
||||
name: "balanceOf",
|
||||
type: "function",
|
||||
stateMutability: "view",
|
||||
inputs: [{ name: "account", type: "address" }],
|
||||
outputs: [{ name: "", type: "uint256" }],
|
||||
},
|
||||
] as const;
|
||||
|
||||
function formatPoolCap(usd: number): string {
|
||||
if (usd >= 1_000_000) return `$${(usd / 1_000_000).toFixed(1)}M`;
|
||||
if (usd >= 1_000) return `$${(usd / 1_000).toFixed(1)}K`;
|
||||
return `$${usd.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense>
|
||||
<HomeContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useApp();
|
||||
const { address: userAddress, chainId: userChainId } = useAccount();
|
||||
const initialView = searchParams.get("view") === "list" ? "list" : "grid";
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">(initialView);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
const { data: products = [], isLoading: productsLoading } = useQuery({
|
||||
queryKey: ["fundmarket-products"],
|
||||
queryFn: () => fetchProducts(),
|
||||
});
|
||||
|
||||
const { data: stats = [], isLoading: statsLoading } = useQuery({
|
||||
queryKey: ["fundmarket-stats"],
|
||||
queryFn: () => fetchStats(),
|
||||
});
|
||||
|
||||
// 从合约注册表获取 YTAssetFactory 地址(复用已缓存的 contract-registry 查询)
|
||||
const { data: contractConfigs = [] } = useQuery({
|
||||
queryKey: ["contract-registry"],
|
||||
queryFn: fetchContracts,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const factoryByChain = useMemo(() => {
|
||||
const map: Record<number, `0x${string}`> = {};
|
||||
for (const c of contractConfigs) {
|
||||
if (c.name === "YTAssetFactory" && c.address) {
|
||||
map[c.chain_id] = c.address as `0x${string}`;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [contractConfigs]);
|
||||
|
||||
// 为每个有合约地址的产品构建 getVaultInfo 调用
|
||||
const vaultContracts = useMemo(() => {
|
||||
return products
|
||||
.filter((p) => p.contractAddress && factoryByChain[p.chainId])
|
||||
.map((p) => ({
|
||||
address: factoryByChain[p.chainId],
|
||||
abi: abis.YTAssetFactory as any,
|
||||
functionName: "getVaultInfo" as const,
|
||||
args: [p.contractAddress as `0x${string}`],
|
||||
chainId: p.chainId,
|
||||
}));
|
||||
}, [products, factoryByChain]);
|
||||
|
||||
const { data: vaultData, isLoading: isVaultLoading } = useReadContracts({
|
||||
contracts: vaultContracts,
|
||||
query: { enabled: vaultContracts.length > 0 },
|
||||
});
|
||||
|
||||
// 超时 3 秒后不再等合约数据,直接渲染
|
||||
const [vaultTimedOut, setVaultTimedOut] = useState(false);
|
||||
useEffect(() => {
|
||||
if (vaultContracts.length === 0) return;
|
||||
setVaultTimedOut(false);
|
||||
const t = setTimeout(() => setVaultTimedOut(true), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [vaultContracts.length]);
|
||||
|
||||
const contractsReady = vaultContracts.length === 0 || vaultData !== undefined || vaultTimedOut;
|
||||
|
||||
// 从合约实时数据提取 Pool Cap / Pool Capacity % / Maturity
|
||||
const formatLockUp = (nextRedemptionTime: bigint): string => {
|
||||
const ts = Number(nextRedemptionTime);
|
||||
if (!ts) return '--';
|
||||
const diff = ts * 1000 - Date.now();
|
||||
if (diff <= 0) return 'Matured';
|
||||
const totalDays = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
if (totalDays >= 365) return `${Math.floor(totalDays / 365)}y ${totalDays % 365}d`;
|
||||
if (totalDays >= 30) return `${Math.floor(totalDays / 30)}mo`;
|
||||
return `${totalDays}d`;
|
||||
};
|
||||
|
||||
const vaultInfoMap = useMemo(() => {
|
||||
if (!vaultData) return {} as Record<number, { poolCap: string; poolCapacityPercent: number; maturity?: string; lockUp: string; status: 'active' | 'full' | 'ended' }>;
|
||||
const map: Record<number, { poolCap: string; poolCapacityPercent: number; maturity?: string; lockUp: string; status: 'active' | 'full' | 'ended' }> = {};
|
||||
const eligibleProducts = products.filter(
|
||||
(p) => p.contractAddress && factoryByChain[p.chainId]
|
||||
);
|
||||
eligibleProducts.forEach((p, i) => {
|
||||
const entry = vaultData[i];
|
||||
if (entry?.status !== "success" || !entry.result) return;
|
||||
// getVaultInfo: [exists, totalAssets, idleAssets, managedAssets, totalSupply, hardCap, usdcPrice, ytPrice, nextRedemptionTime]
|
||||
const res = entry.result as [boolean, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint];
|
||||
const hardCap = res[5];
|
||||
const totalSupply = res[4];
|
||||
const nextRedemptionTime = res[8];
|
||||
if (hardCap === 0n) return;
|
||||
|
||||
// Pool Cap = hardCap 直接读取(18 dec YT tokens)
|
||||
const poolCapTokens = Number(hardCap) / 1e18;
|
||||
const percent = (Number(totalSupply) / Number(hardCap)) * 100;
|
||||
|
||||
// Maturity = nextRedemptionTime(Unix 秒),0 表示未配置,留 undefined 让外层 fallback API 值
|
||||
let maturity: string | undefined;
|
||||
if (nextRedemptionTime > 0n) {
|
||||
const date = new Date(Number(nextRedemptionTime) * 1000);
|
||||
const dateStr = date.toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
maturity = date < new Date() ? `${dateStr} (Matured)` : dateStr;
|
||||
}
|
||||
|
||||
const isMatured = nextRedemptionTime > 0n && Number(nextRedemptionTime) * 1000 < Date.now();
|
||||
const isFull = hardCap > 0n && totalSupply >= hardCap;
|
||||
const status: 'active' | 'full' | 'ended' = isMatured ? 'ended' : isFull ? 'full' : 'active';
|
||||
|
||||
map[p.id] = {
|
||||
poolCap: poolCapTokens > 0 ? formatPoolCap(poolCapTokens) : "--",
|
||||
poolCapacityPercent: Math.min(percent, 100),
|
||||
maturity,
|
||||
lockUp: formatLockUp(nextRedemptionTime),
|
||||
status,
|
||||
};
|
||||
});
|
||||
return map;
|
||||
}, [vaultData, products, factoryByChain]);
|
||||
|
||||
// ERC20 balanceOf batch — one call per product token contract, requires wallet connected
|
||||
const balanceContracts = useMemo(() => {
|
||||
if (!userAddress) return [];
|
||||
return products
|
||||
.filter((p) => p.contractAddress && factoryByChain[p.chainId])
|
||||
.map((p) => ({
|
||||
address: p.contractAddress as `0x${string}`,
|
||||
abi: erc20BalanceOfAbi,
|
||||
functionName: "balanceOf" as const,
|
||||
args: [userAddress],
|
||||
chainId: p.chainId,
|
||||
}));
|
||||
}, [products, factoryByChain, userAddress]);
|
||||
|
||||
const { data: balanceData } = useReadContracts({
|
||||
contracts: balanceContracts,
|
||||
query: { enabled: balanceContracts.length > 0 },
|
||||
});
|
||||
|
||||
// Compute "Your Total Balance" = Σ(tokenBalance[i] * ytPrice[i])
|
||||
// ytPrice has 30 decimals, balance has 18 decimals → divide by 10^48
|
||||
const totalBalanceUSD = useMemo(() => {
|
||||
if (!userAddress || !vaultData || !balanceData) return null;
|
||||
const eligibleProducts = products.filter(
|
||||
(p) => p.contractAddress && factoryByChain[p.chainId]
|
||||
);
|
||||
let totalUsdMillionths = 0n;
|
||||
eligibleProducts.forEach((_, i) => {
|
||||
const vEntry = vaultData[i];
|
||||
const bEntry = balanceData[i];
|
||||
if (vEntry?.status !== "success" || !vEntry.result) return;
|
||||
if (bEntry?.status !== "success" || bEntry.result === undefined) return;
|
||||
const res = vEntry.result as [boolean, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint];
|
||||
const ytPriceRaw = res[7];
|
||||
const balanceRaw = bEntry.result as bigint;
|
||||
if (!ytPriceRaw || !balanceRaw) return;
|
||||
// Multiply first, divide once: (balance * ytPrice * 1e6) / 10^48
|
||||
totalUsdMillionths += (balanceRaw * ytPriceRaw * 1_000_000n) / (10n ** 48n);
|
||||
});
|
||||
return Number(totalUsdMillionths) / 1_000_000;
|
||||
}, [userAddress, vaultData, balanceData, products, factoryByChain]);
|
||||
|
||||
// Fetch net USDC deposited from backend (for earning calculation)
|
||||
const { data: netDepositedData } = useQuery({
|
||||
queryKey: ["net-deposited", userAddress, userChainId],
|
||||
queryFn: async () => {
|
||||
if (!userAddress) return null;
|
||||
const chainId = userChainId ?? 97;
|
||||
const res = await fetch(`/api/fundmarket/net-deposited?address=${userAddress.toLowerCase()}&chain_id=${chainId}`);
|
||||
const json = await res.json();
|
||||
return json.data as { netDepositedUSD: number } | null;
|
||||
},
|
||||
enabled: !!userAddress,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
// Your Total Earning = currentValue (totalBalanceUSD) - netDepositedUSD
|
||||
// Only compute when netDepositedUSD > 0 (i.e. scanner has real data)
|
||||
const totalEarningUSD = useMemo(() => {
|
||||
if (totalBalanceUSD === null || !netDepositedData) return null;
|
||||
if (netDepositedData.netDepositedUSD <= 0) return null;
|
||||
return totalBalanceUSD - netDepositedData.netDepositedUSD;
|
||||
}, [totalBalanceUSD, netDepositedData]);
|
||||
|
||||
// Stat label translation keys in API order
|
||||
const statLabelKeys = [
|
||||
"productPage.totalValueLocked",
|
||||
"productPage.cumulativeYield",
|
||||
"productPage.yourTotalBalance",
|
||||
"productPage.yourTotalEarning",
|
||||
];
|
||||
|
||||
// Override stats[2] (Your Total Balance) with computed on-chain value
|
||||
const displayStats = useMemo(() => {
|
||||
if (!stats.length) return stats;
|
||||
const result = stats.map((stat, i) => ({
|
||||
...stat,
|
||||
label: statLabelKeys[i] ? t(statLabelKeys[i]) : stat.label,
|
||||
}));
|
||||
if (result[2] && totalBalanceUSD !== null) {
|
||||
result[2] = {
|
||||
...result[2],
|
||||
value: totalBalanceUSD > 0
|
||||
? `$${totalBalanceUSD >= 1_000_000
|
||||
? `${(totalBalanceUSD / 1_000_000).toFixed(2)}M`
|
||||
: totalBalanceUSD >= 1_000
|
||||
? `${(totalBalanceUSD / 1_000).toFixed(2)}K`
|
||||
: totalBalanceUSD.toFixed(2)}`
|
||||
: "$0.00",
|
||||
};
|
||||
}
|
||||
if (result[3] && totalEarningUSD !== null) {
|
||||
const isPositive = totalEarningUSD >= 0;
|
||||
const absVal = Math.abs(totalEarningUSD);
|
||||
const formatted = absVal >= 1_000_000
|
||||
? `${(absVal / 1_000_000).toFixed(2)}M`
|
||||
: absVal >= 1_000
|
||||
? `${(absVal / 1_000).toFixed(2)}K`
|
||||
: absVal.toFixed(2);
|
||||
result[3] = {
|
||||
...result[3],
|
||||
value: `${isPositive ? '' : '-'}$${formatted}`,
|
||||
isPositive,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}, [stats, totalBalanceUSD, totalEarningUSD, t]);
|
||||
|
||||
const loading = productsLoading || statsLoading || (isVaultLoading && !contractsReady);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/market" },
|
||||
{ label: "Fund Market" },
|
||||
];
|
||||
|
||||
const handleViewChange = useCallback((newMode: "grid" | "list") => {
|
||||
if (newMode === viewMode) return;
|
||||
setIsTransitioning(true);
|
||||
// 同步更新 URL search params(replaceState,不产生历史记录)
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (newMode === "grid") {
|
||||
params.delete("view");
|
||||
} else {
|
||||
params.set("view", newMode);
|
||||
}
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);
|
||||
setTimeout(() => {
|
||||
setViewMode(newMode);
|
||||
setIsTransitioning(false);
|
||||
}, 200);
|
||||
}, [viewMode, searchParams]);
|
||||
|
||||
const handleInvest = (productId: number) => {
|
||||
router.push(`/market/product/${productId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
{/* Top Bar */}
|
||||
<div className="bg-gray-100 dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="px-4 md:px-8 pt-4 md:pt-8 pb-4 md:pb-8">
|
||||
{/* Page Title */}
|
||||
<PageTitle title="AssetX Fund Market" />
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="mb-8">
|
||||
{loading ? (
|
||||
<StatsCardsSkeleton />
|
||||
) : (
|
||||
<StatsCards stats={displayStats} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assets Section */}
|
||||
<div className="flex flex-col gap-6 mt-8 md:mt-16">
|
||||
{/* Section Header with View Toggle */}
|
||||
<SectionHeader title="Assets">
|
||||
<ViewToggle value={viewMode} onChange={handleViewChange} />
|
||||
</SectionHeader>
|
||||
|
||||
{/* Product Cards - Grid or List View */}
|
||||
<div
|
||||
className="transition-opacity duration-200"
|
||||
style={{ opacity: isTransitioning ? 0 : 1 }}
|
||||
>
|
||||
{loading ? (
|
||||
viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((index) => (
|
||||
<div key={index}>
|
||||
<ProductCardSkeleton />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{[1, 2, 3].map((index) => (
|
||||
<ProductCardListSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{products.map((product, index) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="animate-fade-in"
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}}
|
||||
>
|
||||
<ProductCard
|
||||
productId={product.id}
|
||||
name={product.name}
|
||||
category={product.category}
|
||||
categoryColor={product.categoryColor}
|
||||
iconUrl={product.iconUrl}
|
||||
yieldAPY={product.yieldAPY}
|
||||
poolCap={vaultInfoMap[product.id]?.poolCap ?? product.poolCap}
|
||||
maturity={vaultInfoMap[product.id]?.maturity || "--"}
|
||||
risk={product.risk}
|
||||
riskLevel={product.riskLevel}
|
||||
lockUp={vaultInfoMap[product.id]?.lockUp ?? '--'}
|
||||
circulatingSupply={product.circulatingSupply}
|
||||
poolCapacityPercent={vaultInfoMap[product.id]?.poolCapacityPercent ?? product.poolCapacityPercent}
|
||||
status={vaultInfoMap[product.id]?.status}
|
||||
onInvest={() => handleInvest(product.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{products.map((product, index) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="animate-fade-in"
|
||||
style={{
|
||||
animationDelay: `${index * 0.08}s`,
|
||||
}}
|
||||
>
|
||||
<ProductCardList
|
||||
productId={product.id}
|
||||
name={product.name}
|
||||
category={product.category}
|
||||
categoryColor={product.categoryColor}
|
||||
iconUrl={product.iconUrl}
|
||||
poolCap={vaultInfoMap[product.id]?.poolCap ?? product.poolCap}
|
||||
lockUp={vaultInfoMap[product.id]?.lockUp ?? '--'}
|
||||
poolCapacityPercent={vaultInfoMap[product.id]?.poolCapacityPercent ?? product.poolCapacityPercent}
|
||||
status={vaultInfoMap[product.id]?.status}
|
||||
onInvest={() => handleInvest(product.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
webapp/app/market/product/[id]/page.tsx
Normal file
75
webapp/app/market/product/[id]/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import ContentSection from "@/components/product/ContentSection";
|
||||
import ProductDetailSkeleton from "@/components/product/ProductDetailSkeleton";
|
||||
import { fetchProductDetail, ProductDetail } from "@/lib/api/fundmarket";
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const params = useParams();
|
||||
const productId = params.id as string;
|
||||
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProduct() {
|
||||
if (!productId) return;
|
||||
|
||||
setLoading(true);
|
||||
const data = await fetchProductDetail(parseInt(productId));
|
||||
setProduct(data);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
loadProduct();
|
||||
}, [productId]);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/market" },
|
||||
{ label: "Fund Market", href: "/market" },
|
||||
{ label: product?.name || "Loading..." },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-subtle dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3 md:py-6">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
<div className="flex-1 px-4 py-4 md:px-8 md:py-8 bg-[#F3F4F6] dark:bg-gray-900 overflow-x-hidden">
|
||||
<ProductDetailSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-subtle dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-text-primary dark:text-white">Product not found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-subtle dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3 md:py-6">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 py-4 md:px-8 md:py-8 bg-[#F3F4F6] dark:bg-gray-900 overflow-x-hidden">
|
||||
{/* Tab Navigation and Content */}
|
||||
<ContentSection product={product} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
webapp/app/page.tsx
Normal file
5
webapp/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/market");
|
||||
}
|
||||
32
webapp/app/points/page.tsx
Normal file
32
webapp/app/points/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import PointsDashboard from "@/components/points/PointsDashboard";
|
||||
import PointsCards from "@/components/points/PointsCards";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
|
||||
export default function PointsPage() {
|
||||
const { t } = useApp();
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/" },
|
||||
{ label: t("nav.points") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 md:px-8 py-4 md:py-8 bg-[#F3F4F6] dark:bg-gray-900 flex flex-col gap-6">
|
||||
<PointsDashboard />
|
||||
<PointsCards />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
webapp/app/product/[id]/page.tsx
Normal file
10
webapp/app/product/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ProductRedirectPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
redirect(`/market/product/${id}`);
|
||||
}
|
||||
38
webapp/app/swap/page.tsx
Normal file
38
webapp/app/swap/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import TradePanel from "@/components/common/TradePanel";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
|
||||
export default function SwapPage() {
|
||||
const { t } = useApp();
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/" },
|
||||
{ label: t("nav.swap") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F3F4F6] dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4 md:p-8 bg-[#F3F4F6] dark:bg-gray-900">
|
||||
<div className="h-full flex items-start md:items-center justify-center">
|
||||
<div className="w-full md:w-auto bg-white dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8">
|
||||
<TradePanel
|
||||
showHeader={true}
|
||||
title={t("swap.title")}
|
||||
subtitle={t("swap.subtitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
webapp/app/transparency/page.tsx
Normal file
53
webapp/app/transparency/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import Sidebar from "@/components/layout/Sidebar";
|
||||
import TopBar from "@/components/layout/TopBar";
|
||||
import TransparencyStats from "@/components/transparency/TransparencyStats";
|
||||
import HoldingsTable from "@/components/transparency/HoldingsTable";
|
||||
import AssetDistribution from "@/components/transparency/AssetDistribution";
|
||||
import GeographicAllocation from "@/components/transparency/GeographicAllocation";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
|
||||
export default function TransparencyPage() {
|
||||
const { t } = useApp();
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "ASSETX", href: "/" },
|
||||
{ label: t("nav.transparency") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white dark:bg-gray-900 flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col md:ml-[240px] pt-14 md:pt-0">
|
||||
<div className="bg-[#F3F4F6] dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 px-4 md:px-8 py-3">
|
||||
<TopBar breadcrumbItems={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-4 md:px-8 py-4 md:py-8 bg-[#F3F4F6] dark:bg-gray-900 flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-1">
|
||||
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">
|
||||
{t("transparency.title")}
|
||||
</h1>
|
||||
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
|
||||
{t("transparency.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<TransparencyStats />
|
||||
|
||||
{/* Holdings Table */}
|
||||
<HoldingsTable />
|
||||
|
||||
{/* Distribution Section */}
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
<AssetDistribution />
|
||||
<GeographicAllocation />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user