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

5
webapp/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.next/
.env.local
*.log
.git

3
webapp/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# WalletConnect Project ID
# 从 https://cloud.walletconnect.com/ 获取你的 Project ID
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_project_id_here

3
webapp/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

21
webapp/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# ---- Build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN npm ci --legacy-peer-deps
COPY . .
ARG BACKEND_URL=http://backend:8080
ENV BACKEND_URL=$BACKEND_URL
RUN npm run build
# ---- Run stage ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3010
ENV BACKEND_URL=http://backend:8080
CMD ["node", "server.js"]

151
webapp/QWEN.md Normal file
View File

@@ -0,0 +1,151 @@
# AssetX Webapp 项目记忆文件
## 项目概述
AssetX 是一个 DeFi 资产管理平台前端,基于 Next.js 15 + React 19 + TypeScript 构建。
## 技术栈
- **框架**: Next.js 15 (App Router)
- **UI库**: React 19, HeroUI, Tailwind CSS
- **Web3**: wagmi, viem, Reown AppKit, MetaMask SDK, Coinbase Wallet SDK, WalletConnect
- **图表**: ECharts
- **动画**: Framer Motion
- **通知**: Sonner
- **包管理**: npm/bun
## 目录结构
```
webapp/
├── app/ # Next.js App Router 页面
│ ├── layout.tsx # 根布局
│ ├── page.tsx # 首页
│ ├── alp/ # ALP (资产流动性池) 模块
│ ├── ecosystem/ # 生态系统页面
│ ├── lending/ # 借贷模块
│ ├── market/ # 市场模块
│ ├── points/ # 积分模块
│ ├── product/ # 产品详情页
│ ├── swap/ # 交易兑换模块
│ └── transparency/ # 透明度页面
├── components/ # React 组件
│ ├── alp/ # ALP 相关组件
│ ├── common/ # 通用组件
│ ├── fundmarket/ # 基金市场组件
│ ├── icons/ # 图标组件
│ ├── layout/ # 布局组件 (Sidebar, TopBar等)
│ ├── lending/ # 借贷相关组件
│ ├── modals/ # 弹窗组件
│ ├── points/ # 积分相关组件
│ ├── product/ # 产品相关组件
│ ├── transparency/ # 透明度组件
│ ├── wallet/ # 钱包组件
│ ├── Providers.tsx # 全局 Provider 包装器
│ └── ResourcePreload.tsx # 资源预加载
├── hooks/ # 自定义 React Hooks
│ ├── useBalance.ts # 余额查询
│ ├── useCollateral.ts # 抵押品操作
│ ├── useContractRegistry.ts # 合约注册表
│ ├── useDeposit.ts # 存款操作
│ ├── useHealthFactor.ts # 健康因子
│ ├── useLendingCollateral.ts # 借贷抵押
│ ├── useLendingSupply.ts # 借贷供应
│ ├── useLendingWithdraw.ts # 借贷提取
│ ├── usePoolDeposit.ts # 池存款
│ ├── usePoolWithdraw.ts # 池提取
│ ├── useSwap.ts # 交换操作
│ ├── useTokenBySymbol.ts # 按符号获取代币
│ ├── useTokenDecimals.ts # 代币精度
│ ├── useTokenList.ts # 代币列表
│ ├── useWalletStatus.ts # 钱包状态
│ └── useWithdraw.ts # 提款操作
├── lib/ # 工具库
│ ├── api/ # API 接口
│ │ ├── contracts.ts # 合约 API
│ │ ├── fundmarket.ts # 基金市场 API
│ │ ├── lending.ts # 借贷 API
│ │ ├── points.ts # 积分 API
│ │ └── tokens.ts # 代币 API
│ ├── contracts/ # 合约相关
│ │ ├── abis/ # 合约 ABI 文件
│ │ │ ├── lendingProxy.json
│ │ │ ├── USDY.json
│ │ │ ├── YT-Token.json
│ │ │ ├── YTAssetFactory.json
│ │ │ ├── YTLPToken.json
│ │ │ ├── YTPoolManager.json
│ │ │ ├── YTPriceFeed.json
│ │ │ ├── YTRewardRouter.json
│ │ │ └── YTVault.json
│ │ ├── index.ts
│ │ └── registry.ts # 动态合约地址注册表
│ ├── wagmi.ts # Wagmi 配置
│ └── buttonStyles.ts # 按钮样式
├── contexts/ # React Context
│ └── AppContext.tsx # 全局应用状态
├── locales/ # 国际化
│ ├── en.json # 英文
│ └── zh.json # 中文
├── public/ # 静态资源
├── scripts/ # 脚本工具
└── logs/ # 日志目录
```
## 支持的区块链网络
- Ethereum Mainnet
- Sepolia (测试网)
- Arbitrum
- Base
- BSC (Binance Smart Chain)
- Arbitrum Sepolia (测试网)
- BSC Testnet
## 核心功能模块
1. **Product** - 产品详情展示 (APY, TVL, 资产概述等)
2. **ALP** - 资产流动性池
3. **Swap** - 代币交换
4. **Lending** - 借贷系统
5. **Transparency** - 透明度/验证
6. **Points** - 积分系统
7. **Ecosystem** - 生态系统
8. **Market** - 市场模块
## 常用命令
```bash
npm run dev # 开发模式 (端口 3010)
npm run build # 构建生产版本
npm start # 启动生产服务器
npm run lint # 代码检查
```
## 钱包支持
- MetaMask
- OKX Wallet
- Trust Wallet
- Coinbase Wallet
- Backpack Wallet
- WalletConnect
## 合约 ABI 文件
项目包含以下智能合约 ABI:
- `YTVault` - 金库合约
- `YTPoolManager` - 池管理合约
- `YTLPToken` - LP 代币合约
- `YT-Token` - YT 代币合约
- `YTAssetFactory` - 资产工厂合约
- `YTPriceFeed` - 价格预言机合约
- `YTRewardRouter` - 奖励路由合约
- `USDY` - USDY 代币合约
- `lendingProxy` - 借贷代理合约
## 开发注意事项
- 使用 App Router (Next.js 15)
- 全局状态通过 `AppContext.tsx` 管理
- Web3 配置在 `lib/wagmi.ts`
- 合约地址支持动态注册 (`lib/contracts/registry.ts`)
- 支持中英文国际化 (`locales/`)

188
webapp/README.md Normal file
View File

@@ -0,0 +1,188 @@
# AssetX Dashboard - Next.js
这是一个使用 Next.js 和 TypeScript 复刻的 DeFi 资产管理平台界面。
## 技术栈
- **Next.js 15** - React 框架
- **TypeScript** - 类型安全
- **Tailwind CSS** - 样式框架
- **React 19** - UI 库
## 项目结构
```
asset-dashboard-next/
├── app/ # Next.js App Router
│ ├── layout.tsx # 根布局
│ ├── page.tsx # 首页
│ └── globals.css # 全局样式
├── components/ # React 组件 (14个)
│ ├── Sidebar.tsx # 侧边栏主组件
│ ├── NavItem.tsx # 导航项组件
│ ├── TopBar.tsx # 顶部导航栏组件
│ ├── Breadcrumb.tsx # 面包屑导航组件
│ ├── TabNavigation.tsx # 标签页导航组件
│ ├── ContentSection.tsx # 内容区域组件
│ ├── OverviewTab.tsx # Overview 标签页主组件
│ ├── ProductHeader.tsx # 产品标题组件
│ ├── StatsCards.tsx # 数据统计卡片组件
│ ├── AssetOverviewCard.tsx # 资产概览卡片组件
│ ├── APYHistoryCard.tsx # APY 历史图表组件
│ └── AssetDescriptionCard.tsx # 资产描述卡片组件
├── public/ # 静态资源
│ ├── logo.svg # ASSETX Logo
│ └── icon-*.svg # 导航图标
├── tailwind.config.ts # Tailwind 配置
├── tsconfig.json # TypeScript 配置
└── package.json # 项目依赖
```
## 已完成功能
### ✅ 左侧菜单栏 (Sidebar)
- **Logo 展示**: 顶部 ASSETX 品牌标志
- **导航菜单**:
- Assets (资产)
- ALP (资产流动性池)
- Swap (交易兑换)
- Lending (借贷)
- Transparency (透明度)
- Ecosystem (生态系统)
- Points (积分)
- **激活状态**: 点击菜单项显示激活背景色
- **Global TVL 展示**: 显示全局总锁仓价值 $465,000,000
- **FAQs 链接**: 底部帮助入口
### ✅ 顶部导航栏 (TopBar)
- **面包屑导航**: ASSETX > Product > Detail
- **钱包按钮**: 连接钱包图标和通知
- **地址按钮**: 显示钱包地址 0x12...4F82,带复制图标
### ✅ 标签页导航 (TabNavigation)
- **5个标签页**: Overview, Asset Description, Analytics, Performance Analysis, Asset Custody & Verification
- **激活状态**: 点击切换标签,激活标签底部显示下划线
- **状态管理**: 使用 React Hooks 管理当前激活标签
### ✅ Overview 标签页内容
**产品标题区域** (ProductHeader):
- 产品 Logo 展示
- 中英文产品名称:高盈美股量化策略 / High-Yield US Equity Quantitative Strategy
- 智能合约地址Contract: 0x1b19...4f2c
**数据统计卡片** (StatsCards):
- APY: 22% (+2.5% WoW) - 橙色高亮
- Total TVL: $240.5M (+$2.3M Today)
- 24h Volume: $12.8M (↑ 23% vs Avg)
- Your Balance: 0.00 ($0.00 USD)
- Your Earnings: $0.00 (All Time)
**资产概览卡片** (AssetOverviewCard):
- Asset Overview 标题 + Medium Risk 标签
- Underlying Assets: US Equity Index
- Maturity Range: 05 Feb 2026
- Cap: $50,000,000
- Min. Investment: 100 USDC
- Pool Capacity: 75% (带进度条)
- Current Price: 1 GY-US = 1.04 USDC
**APY 历史图表** (APYHistoryCard):
- APY History / Price History 切换标签
- Last 30 days 时间范围
- 简单柱状图展示
- Highest: 24.8% / Lowest: 18.2%
**资产描述** (AssetDescriptionCard):
- 完整的产品描述文本
- 中英文混合内容
- 机构级 RWA 产品介绍
### 组件化设计
**导航组件** (6个):
- `Sidebar.tsx`: 主侧边栏组件,包含完整布局和状态管理
- `NavItem.tsx`: 可复用的导航项组件,支持激活状态切换
- `TopBar.tsx`: 顶部导航栏,包含面包屑和操作按钮
- `Breadcrumb.tsx`: 可复用的面包屑导航组件
- `TabNavigation.tsx`: 可复用的标签页导航组件
- `ContentSection.tsx`: 内容区域容器组件,管理标签页切换
**Overview 内容组件** (6个):
- `OverviewTab.tsx`: Overview 标签页主容器,管理布局
- `ProductHeader.tsx`: 产品标题和基本信息
- `StatsCards.tsx`: 5个数据统计卡片网格
- `AssetOverviewCard.tsx`: 资产概览详细信息
- `APYHistoryCard.tsx`: 带标签切换的历史数据图表
- `AssetDescriptionCard.tsx`: 产品描述文本内容
**总计**: 12个功能组件 + 2个布局组件 = 14个组件
### 样式特点
- **完整复刻原型设计**: 像素级还原原型 UI
- **响应式布局**: Grid/Flexbox 布局系统
- **交互效果**: 悬停、点击、激活状态
- **Tailwind CSS**: 自定义颜色、字号、间距主题
- **字体系统**:
- Inter (400/500/700/800) - UI 文本
- JetBrains Mono (500/700/800) - 代码/数字
- 详见 [FONT-SYSTEM.md](./FONT-SYSTEM.md)
## 运行项目
```bash
# 安装依赖
npm install
# 开发模式运行
npm run dev
# 构建生产版本
npm run build
# 启动生产服务器
npm start
```
开发服务器将在 [http://localhost:3000](http://localhost:3000) 启动。
## 进度跟踪
### 已完成 ✅
- [x] 左侧导航栏 (Sidebar + NavItem)
- [x] 顶部导航栏 (TopBar + Breadcrumb)
- [x] 标签页导航 (TabNavigation)
- [x] Overview 标签页完整内容
- [x] 产品标题和基本信息
- [x] 5个数据统计卡片
- [x] 资产概览卡片
- [x] APY 历史图表
- [x] 资产描述
- [x] 字体系统配置 (Inter + JetBrains Mono)
### 进行中 🚧
- [ ] 右侧交易面板 (Mint/Swap/Deposit 表单)
- [ ] Asset Description 标签页内容
- [ ] Analytics 标签页内容
### 待开发 📋
- [ ] Performance Analysis 标签页 (日历视图)
- [ ] Asset Custody & Verification 标签页 (验证表格)
- [ ] 协议信息模块
- [ ] Season 1 Rewards 模块
- [ ] 响应式适配移动端
- [ ] 深色模式支持
## 文档
- [README.md](./README.md) - 项目总览
- [COMPONENTS.md](./COMPONENTS.md) - 组件 API 文档
- [FONT-SYSTEM.md](./FONT-SYSTEM.md) - 字体系统配置详解

58
webapp/app/alp/page.tsx Normal file
View 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>
);
}

View 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&apos;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
View 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
View 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
View 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>
);
}

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

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

View 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
View 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 = nextRedemptionTimeUnix 秒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 paramsreplaceState不产生历史记录
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>
);
}

View 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
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/market");
}

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

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

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

7
webapp/appkit.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare namespace JSX {
interface IntrinsicElements {
'appkit-button': { size?: 'sm' | 'md'; label?: string; loadingLabel?: string; disabled?: boolean };
'appkit-network-button': Record<string, unknown>;
'appkit-account-button': { balance?: 'show' | 'hide' };
}
}

2009
webapp/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
"use client";
import { HeroUIProvider } from "@heroui/react";
import { AppProvider } from "@/contexts/AppContext";
import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactNode, useState } from "react";
import { config } from '@/lib/wagmi'
import { Toaster } from "sonner";
import { useContractRegistry } from '@/hooks/useContractRegistry'
// Populates the dynamic contract registry from /api/contracts.
// Must be inside QueryClientProvider.
function ContractRegistryInit() {
useContractRegistry()
return null
}
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<ContractRegistryInit />
<HeroUIProvider>
<AppProvider>
<Toaster
position="top-right"
richColors
closeButton
duration={4000}
/>
{children}
</AppProvider>
</HeroUIProvider>
</QueryClientProvider>
</WagmiProvider>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { useEffect } from 'react';
// 预加载关键资源
const criticalResources = [
// 导航图标
'/icons/navigation/icon-lending.svg',
'/icons/navigation/icon-alp.svg',
'/icons/navigation/icon-swap.svg',
'/icons/navigation/icon-transparency.svg',
'/icons/navigation/icon-points.svg',
// 常用操作图标
'/icons/actions/icon-wallet.svg',
'/icons/actions/icon-notification.svg',
'/icons/actions/icon-copy.svg',
// Logo
'/logos/logo.svg',
];
export default function ResourcePreload() {
useEffect(() => {
// 使用浏览器原生预加载API
criticalResources.forEach(url => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.as = 'image';
link.href = url;
document.head.appendChild(link);
});
// 可选使用fetch预加载并缓存
if ('caches' in window) {
caches.open('svg-cache-v1').then(cache => {
cache.addAll(criticalResources).catch(err => {
console.error('预加载SVG失败:', err);
});
});
}
}, []);
return null;
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useReadContracts } from 'wagmi';
import { useAccount } from 'wagmi';
import { formatUnits } from 'viem';
import { useQuery } from '@tanstack/react-query';
import { useApp } from "@/contexts/AppContext";
import { getContractAddress, abis } from '@/lib/contracts';
function formatUSD(v: number): string {
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (v >= 1_000) return `$${(v / 1_000).toFixed(2)}K`;
return `$${v.toFixed(2)}`;
}
export default function ALPStatsCards() {
const { t } = useApp();
const { chainId } = useAccount();
const poolManagerAddress = chainId ? getContractAddress('YTPoolManager', chainId) : undefined;
const lpTokenAddress = chainId ? getContractAddress('YTLPToken', chainId) : undefined;
const { data: tvlData, isLoading } = useReadContracts({
contracts: [
{
address: poolManagerAddress,
abi: abis.YTPoolManager as any,
functionName: 'getAumInUsdy',
args: [true],
chainId: chainId,
},
{
address: lpTokenAddress,
abi: abis.YTLPToken as any,
functionName: 'totalSupply',
args: [],
chainId: chainId,
},
],
query: { refetchInterval: 30000, enabled: !!poolManagerAddress && !!lpTokenAddress && !!chainId },
});
const aumRaw = tvlData?.[0]?.result as bigint | undefined;
const supplyRaw = tvlData?.[1]?.result as bigint | undefined;
const tvlValue = aumRaw ? formatUSD(parseFloat(formatUnits(aumRaw, 18))) : (isLoading ? '...' : '--');
const alpPrice = aumRaw && supplyRaw && supplyRaw > 0n
? `$${(parseFloat(formatUnits(aumRaw, 18)) / parseFloat(formatUnits(supplyRaw, 18))).toFixed(4)}`
: (isLoading ? '...' : '--');
// Fetch APR from backend (requires historical snapshots)
const { data: aprData } = useQuery({
queryKey: ['alp-stats'],
queryFn: async () => {
const res = await fetch('/api/alp/stats');
const json = await res.json();
return json.data as { poolAPR: number; rewardAPR: number };
},
refetchInterval: 5 * 60 * 1000, // refresh every 5 min
});
const poolAPR = aprData?.poolAPR != null
? `${aprData.poolAPR.toFixed(4)}%`
: '--';
const stats = [
{
label: t("stats.totalValueLocked"),
value: tvlValue,
isGreen: false,
},
{
label: t("alp.price"),
value: alpPrice,
isGreen: false,
},
{
label: t("alp.poolAPR"),
value: poolAPR,
isGreen: aprData?.poolAPR != null && aprData.poolAPR > 0,
},
{
label: t("alp.rewardAPR"),
value: "--",
isGreen: true,
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
{stats.map((stat, index) => (
<div
key={index}
className="bg-bg-subtle dark:bg-gray-800 rounded-2xl border border-border-gray dark:border-gray-700 px-4 md:px-6 py-4 flex flex-col gap-2"
>
<div className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">
{stat.label}
</div>
<div className={`text-xl md:text-[32px] font-bold leading-[130%] tracking-[-0.01em] ${stat.isGreen ? 'text-[#10b981]' : 'text-text-primary dark:text-white'}`}>
{stat.value}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,317 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo } from "react";
import { useReadContracts } from 'wagmi';
import { useAccount } from 'wagmi';
import { formatUnits } from 'viem';
import { useQuery } from '@tanstack/react-query';
import { useApp } from "@/contexts/AppContext";
import { getContractAddress, abis } from '@/lib/contracts';
function formatUSD(v: number): string {
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (v >= 1_000) return `$${(v / 1_000).toFixed(2)}K`;
return `$${v.toFixed(2)}`;
}
export default function LiquidityAllocationTable({ refreshTrigger }: { refreshTrigger?: number }) {
const { t } = useApp();
const { chainId } = useAccount();
// Fetch all products from backend; filter non-stablecoins to build YT token list
const { data: products = [] } = useQuery<any[]>({
queryKey: ['products'],
queryFn: async () => {
const res = await fetch('/api/fundmarket/products?token_list=1');
const json = await res.json();
return json.data ?? [];
},
staleTime: 5 * 60 * 1000,
});
// Build dynamic YT token list from DB products (only yt_token role)
// Use contractAddress and chainId directly from product data
const ytTokens = useMemo(() =>
products
.filter((p: any) => p.token_role === 'yt_token' && p.contractAddress)
.map((p: any) => ({
key: p.tokenSymbol as string,
address: p.contractAddress as string,
chainId: p.chainId as number,
meta: p,
})),
[products]);
// Derive target chainId from products, fallback to connected wallet
const targetChainId = ytTokens[0]?.chainId ?? chainId;
// System contract addresses from registry
const ytVaultAddr = targetChainId ? getContractAddress('YTVault', targetChainId) : undefined;
const usdcAddr = targetChainId ? getContractAddress('USDC', targetChainId) : undefined;
const priceFeedAddr = targetChainId ? getContractAddress('YTPriceFeed', targetChainId) : undefined;
const N = ytTokens.length;
const addressesReady = !!(ytVaultAddr && usdcAddr && priceFeedAddr && N > 0 && ytTokens.every(t => t.address));
// Dynamic batch on-chain reads — indices scale with N = ytTokens.length:
// [0] YTVault.getPoolValue(true)
// [1..N] YTVault.usdyAmounts(ytToken[i])
// [N+1..2N] YTPriceFeed.getPrice(ytToken[i], false)
// [2N+1] USDC.balanceOf(ytVaultAddr)
// [2N+2] YTPriceFeed.getPrice(usdcAddr, false)
// [2N+3] YTVault.totalTokenWeights()
// [2N+4..3N+3] YTVault.tokenWeights(ytToken[i])
// [3N+4] YTVault.tokenWeights(usdcAddr)
const { data: chainData, refetch: refetchChain } = useReadContracts({
contracts: [
{
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'getPoolValue',
args: [true],
chainId: targetChainId,
} as const,
...ytTokens.map(tk => ({
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'usdyAmounts',
args: [tk.address],
chainId: targetChainId,
} as const)),
...ytTokens.map(tk => ({
address: priceFeedAddr,
abi: abis.YTPriceFeed as any,
functionName: 'getPrice',
args: [tk.address, false],
chainId: targetChainId,
} as const)),
{
address: usdcAddr,
abi: abis.USDY as any,
functionName: 'balanceOf',
args: [ytVaultAddr],
chainId: targetChainId,
} as const,
{
address: priceFeedAddr,
abi: abis.YTPriceFeed as any,
functionName: 'getPrice',
args: [usdcAddr, false],
chainId: targetChainId,
} as const,
{
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'totalTokenWeights',
chainId: targetChainId,
} as const,
...ytTokens.map(tk => ({
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'tokenWeights',
args: [tk.address],
chainId: targetChainId,
} as const)),
{
address: ytVaultAddr,
abi: abis.YTVault as any,
functionName: 'tokenWeights',
args: [usdcAddr],
chainId: targetChainId,
} as const,
],
query: { refetchInterval: 30000, enabled: addressesReady },
});
useEffect(() => {
if (refreshTrigger) refetchChain();
}, [refreshTrigger]);
// Parse results with dynamic indices
const totalPoolRaw = chainData?.[0]?.result as bigint | undefined;
const totalWeightsRaw = chainData?.[2 * N + 3]?.result as bigint | undefined;
const totalPool = totalPoolRaw ? parseFloat(formatUnits(totalPoolRaw, 18)) : 0;
const totalWeights = totalWeightsRaw ? Number(totalWeightsRaw) : 0;
const ytAllocations = ytTokens.map((token, i) => {
const usdyRaw = chainData?.[1 + i]?.result as bigint | undefined;
const priceRaw = chainData?.[N + 1 + i]?.result as bigint | undefined;
const twRaw = chainData?.[2 * N + 4 + i]?.result as bigint | undefined;
const poolSizeUSD = usdyRaw ? parseFloat(formatUnits(usdyRaw, 18)) : 0;
const ytPrice = priceRaw ? parseFloat(formatUnits(priceRaw, 30)) : 0;
const balance = ytPrice > 0 ? poolSizeUSD / ytPrice : 0;
const currentWeight = totalPool > 0 ? poolSizeUSD / totalPool * 100 : 0;
const targetWeight = totalWeights > 0 && twRaw != null ? Number(twRaw) / totalWeights * 100 : 0;
return {
key: token.key,
name: token.meta?.name ?? token.key,
category: token.meta?.category ?? '--',
iconUrl: token.meta?.iconUrl,
poolSizeUSD,
balance,
ytPrice,
currentWeight,
targetWeight,
isStable: false,
};
});
const usdcRaw = chainData?.[2 * N + 1]?.result as bigint | undefined;
const usdcPriceRaw = chainData?.[2 * N + 2]?.result as bigint | undefined;
const usdcTWRaw = chainData?.[3 * N + 4]?.result as bigint | undefined;
const usdcBalance = usdcRaw ? parseFloat(formatUnits(usdcRaw, 18)) : 0;
const usdcPrice = usdcPriceRaw ? parseFloat(formatUnits(usdcPriceRaw, 30)) : 0;
const usdcPoolSize = usdcBalance * usdcPrice;
const usdcCurWeight = totalPool > 0 ? usdcPoolSize / totalPool * 100 : 0;
const usdcTgtWeight = totalWeights > 0 && usdcTWRaw != null ? Number(usdcTWRaw) / totalWeights * 100 : 0;
const usdcMeta = products.find((p: any) => p.tokenSymbol === 'USDC');
const allocations = [
...ytAllocations,
{
key: 'USDC',
name: usdcMeta?.name ?? 'USD Coin',
category: usdcMeta?.category ?? 'Stablecoin',
iconUrl: usdcMeta?.iconUrl,
poolSizeUSD: usdcPoolSize,
balance: usdcBalance,
ytPrice: usdcPrice,
currentWeight: usdcCurWeight,
targetWeight: usdcTgtWeight,
isStable: true,
},
];
return (
<div className="flex flex-col gap-3">
{/* Title */}
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white">
{t("alp.liquidityAllocation")}
</h2>
{/* 移动端:卡片布局 */}
<div className="md:hidden flex flex-col gap-3">
{allocations.map((item) => (
<div
key={item.key}
className="bg-bg-surface dark:bg-gray-800 rounded-2xl border border-border-gray dark:border-gray-700 p-4 flex flex-col gap-3"
>
{/* Token Header */}
<div className="flex items-center gap-3">
<Image
src={item.iconUrl || '/assets/tokens/default.svg'}
alt={item.key}
width={36}
height={36}
className="rounded-full"
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
/>
<div className="flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white">{item.name}</span>
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{item.category}</span>
</div>
</div>
{/* Info Grid */}
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{t("alp.poolSize")}</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{item.poolSizeUSD > 0 ? formatUSD(item.poolSizeUSD) : '--'}
</span>
<span className="text-caption-tiny text-[#6b7280] dark:text-gray-400">
{item.balance > 0 ? `${item.balance.toFixed(2)} ${item.key}` : '--'}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">Weight</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{totalPool > 0 ? `${item.currentWeight.toFixed(1)}%` : '--'}
</span>
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">
{item.targetWeight > 0 ? `${item.targetWeight.toFixed(1)}%` : '--'}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">{t("alp.currentPrice")}</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{item.ytPrice > 0 ? `$${item.ytPrice.toFixed(4)}` : '--'}
</span>
</div>
</div>
</div>
))}
</div>
{/* 桌面端:表格布局 */}
<div className="hidden md:block bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 overflow-hidden">
<div className="flex flex-col">
{/* Header */}
<div className="flex border-b border-border-gray dark:border-gray-700 flex-shrink-0">
<div className="flex-1 px-6 py-4">
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.token")}</div>
</div>
<div className="flex-1 px-6 py-4">
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.poolSize")}</div>
</div>
<div className="flex-1 px-6 py-4">
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">Weight (Current / Target)</div>
</div>
<div className="flex-1 px-6 py-4">
<div className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">{t("alp.currentPrice")}</div>
</div>
</div>
{/* Body */}
{allocations.map((item) => (
<div key={item.key} className="flex items-center border-b border-border-gray dark:border-gray-700 last:border-b-0">
<div className="flex-1 px-6 py-4 flex items-center gap-3">
<Image
src={item.iconUrl || '/assets/tokens/default.svg'}
alt={item.key}
width={32}
height={32}
className="rounded-full"
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
/>
<div className="flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white">{item.name}</span>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">{item.category}</span>
</div>
</div>
<div className="flex-1 px-6 py-4">
<div className="flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white">
{item.poolSizeUSD > 0 ? formatUSD(item.poolSizeUSD) : '--'}
</span>
<span className="text-caption-tiny font-regular text-[#6b7280] dark:text-gray-400">
{item.balance > 0 ? `${item.balance.toFixed(2)} ${item.key}` : '--'}
</span>
</div>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white">
{totalPool > 0 ? (
<>
{item.currentWeight.toFixed(1)}%
<span className="font-regular text-text-tertiary dark:text-gray-400">
{' / '}{item.targetWeight > 0 ? `${item.targetWeight.toFixed(1)}%` : '--'}
</span>
</>
) : '--'}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white">
{item.ytPrice > 0 ? `$${item.ytPrice.toFixed(4)}` : '--'}
</span>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,541 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import { Button, Tabs, Tab } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount, useReadContract } from 'wagmi';
import { parseUnits, formatUnits } from 'viem';
import { useTokenBalance, useYTLPBalance } from '@/hooks/useBalance';
import { usePoolDeposit } from '@/hooks/usePoolDeposit';
import { usePoolWithdraw } from '@/hooks/usePoolWithdraw';
import { toast } from "sonner";
import TokenSelector from '@/components/common/TokenSelector';
import { Token } from '@/lib/api/tokens';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
import { getContractAddress, abis } from '@/lib/contracts';
interface PoolDepositPanelProps {
onSuccess?: () => void;
}
export default function PoolDepositPanel({ onSuccess }: PoolDepositPanelProps) {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<string>("deposit");
const [selectedToken, setSelectedToken] = useState<string>('USDC');
const [selectedTokenObj, setSelectedTokenObj] = useState<Token | undefined>();
const [outputToken, setOutputToken] = useState<string>('USDC');
const [outputTokenObj, setOutputTokenObj] = useState<Token | undefined>();
const [depositAmount, setDepositAmount] = useState<string>("");
const [withdrawAmount, setWithdrawAmount] = useState<string>("");
const [mounted, setMounted] = useState(false);
const [withdrawCooldownLeft, setWithdrawCooldownLeft] = useState(0); // seconds remaining
useEffect(() => {
setMounted(true);
}, []);
// Track 15-min withdraw cooldown after deposit
useEffect(() => {
const COOLDOWN_MS = 15 * 60 * 1000;
const tick = () => {
const lastDeposit = parseInt(localStorage.getItem('alp_last_deposit_time') ?? '0', 10);
if (!lastDeposit) { setWithdrawCooldownLeft(0); return; }
const remaining = Math.ceil((lastDeposit + COOLDOWN_MS - Date.now()) / 1000);
setWithdrawCooldownLeft(remaining > 0 ? remaining : 0);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, []);
// 处理存入 Token 选择
const handleYTTokenSelect = useCallback((token: Token) => {
setSelectedTokenObj(token);
setSelectedToken(token.symbol);
setDepositAmount("");
}, []);
// 处理输出 Token 选择
const handleOutputTokenSelect = useCallback((token: Token) => {
setOutputTokenObj(token);
setOutputToken(token.symbol);
setWithdrawAmount("");
}, []);
// Web3 集成
const { address, isConnected, chainId } = useAccount();
// 优先从产品合约地址读取精度,失败时回退到 token.decimals
const depositInputDecimals = useTokenDecimalsFromAddress(
selectedTokenObj?.contractAddress,
selectedTokenObj?.decimals ?? 18
);
const depositDisplayDecimals = Math.min(depositInputDecimals, 6);
const ytLPAddress = chainId ? getContractAddress('YTLPToken', chainId) : undefined;
const poolManagerAddress = chainId ? getContractAddress('YTPoolManager', chainId) : undefined;
const withdrawDecimals = useTokenDecimalsFromAddress(ytLPAddress, 18);
const withdrawDisplayDecimals = Math.min(withdrawDecimals, 6);
const outputDecimals = useTokenDecimalsFromAddress(outputTokenObj?.contractAddress, outputTokenObj?.decimals ?? 18);
// 存入估算getAddLiquidityOutput(token, amount) → [usdyAmount, ytLPMintAmount]
const depositAmountWei = depositAmount !== '' && parseFloat(depositAmount) > 0 && selectedTokenObj
? parseUnits(depositAmount, depositInputDecimals)
: undefined;
const { data: depositEstData, isLoading: isDepositEstLoading } = useReadContract({
address: poolManagerAddress,
abi: abis.YTPoolManager as any,
functionName: 'getAddLiquidityOutput',
args: [selectedTokenObj?.contractAddress as `0x${string}`, depositAmountWei ?? 0n],
query: { enabled: !!poolManagerAddress && !!selectedTokenObj?.contractAddress && !!depositAmountWei },
});
const estLPTokens = depositEstData
? parseFloat(formatUnits((depositEstData as [bigint, bigint])[1], 18))
: null;
// 提出估算getRemoveLiquidityOutput(tokenOut, ytLPAmount) → [usdyAmount, amountOut]
const withdrawAmountWei = withdrawAmount !== '' && parseFloat(withdrawAmount) > 0
? parseUnits(withdrawAmount, withdrawDecimals)
: undefined;
const { data: withdrawEstData, isLoading: isWithdrawEstLoading } = useReadContract({
address: poolManagerAddress,
abi: abis.YTPoolManager as any,
functionName: 'getRemoveLiquidityOutput',
args: [outputTokenObj?.contractAddress as `0x${string}`, withdrawAmountWei ?? 0n],
query: { enabled: !!poolManagerAddress && !!outputTokenObj?.contractAddress && !!withdrawAmountWei },
});
const estAmountOut = withdrawEstData
? parseFloat(formatUnits((withdrawEstData as [bigint, bigint])[1], outputDecimals))
: null;
const { formattedBalance: depositBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useTokenBalance(
selectedTokenObj?.contractAddress,
depositInputDecimals
);
const { formattedBalance: outputTokenBalance } = useTokenBalance(
outputTokenObj?.contractAddress,
outputDecimals
);
const { formattedBalance: lpBalance, refetch: refetchLP } = useYTLPBalance();
// Deposit hooks
const {
status: depositStatus,
error: depositError,
isLoading: isDepositLoading,
approveHash: depositApproveHash,
depositHash,
executeApproveAndDeposit,
reset: resetDeposit,
} = usePoolDeposit();
// Withdraw hooks
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawLoading,
approveHash: withdrawApproveHash,
withdrawHash: withdrawTxHash,
executeApproveAndWithdraw,
reset: resetWithdraw,
} = usePoolWithdraw();
// 存款成功后刷新余额,并记录 15 分钟提现冷却开始时间
useEffect(() => {
if (depositStatus === 'success') {
toast.success(t("alp.toast.liquidityAdded"), {
description: t("alp.toast.liquidityAddedDesc"),
duration: 5000,
});
localStorage.setItem('alp_last_deposit_time', Date.now().toString());
refetchBalance();
refetchLP();
onSuccess?.();
const timer = setTimeout(() => {
resetDeposit();
setDepositAmount("");
}, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [depositStatus]);
// 提款成功后刷新余额
useEffect(() => {
if (withdrawStatus === 'success') {
toast.success(t("alp.toast.liquidityRemoved"), {
description: t("alp.toast.liquidityRemovedDesc"),
duration: 5000,
});
refetchBalance();
refetchLP();
onSuccess?.();
const timer = setTimeout(() => {
resetWithdraw();
setWithdrawAmount("");
}, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withdrawStatus]);
// 显示错误提示
useEffect(() => {
if (depositError) {
if (depositError === 'Transaction cancelled') {
toast.warning(t("alp.toast.txCancelled"), {
description: t("alp.toast.txCancelledDesc"),
duration: 3000,
});
} else {
toast.error(t("alp.toast.depositFailed"), {
description: depositError,
duration: 5000,
});
}
}
}, [depositError]);
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.warning(t("alp.toast.txCancelled"), {
description: t("alp.toast.txCancelledDesc"),
duration: 3000,
});
} else {
toast.error(t("alp.toast.withdrawFailed"), {
description: withdrawError,
duration: 5000,
});
}
}
}, [withdrawError]);
// 显示交易hash toast
useEffect(() => {
if (depositApproveHash && depositStatus === 'approving') {
toast.info(t("alp.toast.approvalSubmitted"), {
description: t("alp.toast.approvingTokens"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${depositApproveHash}`, '_blank'),
},
duration: 10000,
});
}
}, [depositApproveHash, depositStatus]);
useEffect(() => {
if (depositHash && depositStatus === 'depositing') {
toast.info(t("alp.toast.depositSubmitted"), {
description: t("alp.toast.addingLiquidity"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${depositHash}`, '_blank'),
},
duration: 10000,
});
}
}, [depositHash, depositStatus]);
useEffect(() => {
if (withdrawApproveHash && withdrawStatus === 'approving') {
toast.info(t("alp.toast.approvalSubmitted"), {
description: t("alp.toast.approvingLP"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${withdrawApproveHash}`, '_blank'),
},
duration: 10000,
});
}
}, [withdrawApproveHash, withdrawStatus]);
useEffect(() => {
if (withdrawTxHash && withdrawStatus === 'withdrawing') {
toast.info(t("alp.toast.withdrawSubmitted"), {
description: t("alp.toast.removingLiquidity"),
action: {
label: t("alp.toast.viewTx"),
onClick: () => window.open(`https://testnet.bscscan.com/tx/${withdrawTxHash}`, '_blank'),
},
duration: 10000,
});
}
}, [withdrawTxHash, withdrawStatus]);
// 切换Tab时重置错误状态和输入框
useEffect(() => {
resetDeposit();
resetWithdraw();
setDepositAmount("");
setWithdrawAmount("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleDepositPercentage = (pct: number) => {
const bal = parseFloat(depositBalance);
if (bal > 0) setDepositAmount(truncateDecimals((bal * pct / 100).toString(), depositDisplayDecimals));
};
const handleWithdrawPercentage = (pct: number) => {
const bal = parseFloat(lpBalance);
if (bal > 0) setWithdrawAmount(truncateDecimals((bal * pct / 100).toString(), withdrawDisplayDecimals));
};
const pctBtnClass = "flex-1 sm:flex-none bg-[#e5e7eb] dark:bg-gray-600 rounded-full px-2 sm:px-2.5 h-[22px] text-[10px] font-medium text-text-primary dark:text-white hover:bg-[#d1d5db] dark:hover:bg-gray-500 transition-colors";
const handleDepositAmountChange = (value: string) => {
if (value === '') { setDepositAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > depositDisplayDecimals) return;
if (parseFloat(value) > parseFloat(depositBalance)) { setDepositAmount(truncateDecimals(depositBalance, depositDisplayDecimals)); return; }
setDepositAmount(value);
};
const handleWithdrawAmountChange = (value: string) => {
if (value === '') { setWithdrawAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > withdrawDisplayDecimals) return;
if (parseFloat(value) > parseFloat(lpBalance)) { setWithdrawAmount(truncateDecimals(lpBalance, withdrawDisplayDecimals)); return; }
setWithdrawAmount(value);
};
const handleDeposit = () => {
if (depositAmount && parseFloat(depositAmount) > 0 && selectedTokenObj) {
executeApproveAndDeposit(selectedTokenObj.contractAddress, depositInputDecimals, depositAmount);
}
};
const handleWithdraw = () => {
if (withdrawAmount && parseFloat(withdrawAmount) > 0 && outputTokenObj) {
executeApproveAndWithdraw(outputTokenObj.contractAddress, outputTokenObj.decimals ?? 18, withdrawAmount);
}
};
const currentStatus = activeTab === 'deposit' ? depositStatus : withdrawStatus;
const isLoading = activeTab === 'deposit' ? isDepositLoading : isWithdrawLoading;
const currentAmount = activeTab === 'deposit' ? depositAmount : withdrawAmount;
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden h-full">
{/* Content */}
<div className="flex flex-col gap-6 p-6 flex-1">
{/* Title */}
<div className="flex flex-col gap-1">
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white">
{t("alp.liquidityPool")}
</h2>
<p className="text-body-small text-text-tertiary dark:text-gray-400">
{t("alp.subtitle")}
</p>
</div>
{/* Tabs */}
<Tabs
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(key as string)}
classNames={{
base: "w-full",
tabList: "w-full bg-bg-subtle dark:bg-gray-700 p-1 rounded-xl",
tab: "h-10",
cursor: "bg-white dark:bg-gray-600",
tabContent: "group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
}}
>
<Tab key="deposit" title={t("alp.deposit")}>
{/* Deposit Panel */}
<div className="flex flex-col gap-4 mt-4">
{/* Input Area */}
<div className="flex flex-col gap-2">
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{t("mintSwap.deposit")}
</span>
<div className="flex items-center gap-1 sm:hidden">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{!mounted ? '0' : (isBalanceLoading ? '...' : parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals }))} {selectedToken}
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<button onClick={() => handleDepositPercentage(25)} className={pctBtnClass}>25%</button>
<button onClick={() => handleDepositPercentage(50)} className={pctBtnClass}>50%</button>
<button onClick={() => handleDepositPercentage(75)} className={pctBtnClass}>75%</button>
<button onClick={() => handleDepositPercentage(100)} className={pctBtnClass}>{t("mintSwap.max")}</button>
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} className="ml-1 hidden sm:block" />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400 hidden sm:block">
{!mounted ? '0' : (isBalanceLoading ? '...' : parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals }))} {selectedToken}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start flex-1">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={depositAmount}
onChange={(e) => handleDepositAmountChange(e.target.value)}
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{depositAmount ? `$${depositAmount}` : "--"}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<TokenSelector
selectedToken={selectedTokenObj}
onSelect={handleYTTokenSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol={selectedToken}
/>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
{!mounted ? `0 ${selectedToken}` : (isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(depositBalance, depositDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: depositDisplayDecimals })} ${selectedToken}`)}
</span>
</div>
</div>
</div>
</div>
{/* Pool Info */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400">
{t("alp.estimatedLP")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400">
{estLPTokens != null
? `${estLPTokens.toLocaleString('en-US', { maximumFractionDigits: 6 })} LP`
: depositAmount ? (isDepositEstLoading ? '...' : '--') : '--'}
</span>
</div>
</div>
</div>
</Tab>
<Tab key="withdraw" title={t("alp.withdraw")}>
{/* Withdraw Panel */}
<div className="flex flex-col gap-4 mt-4">
{/* Input Area */}
<div className="flex flex-col gap-2">
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{t("alp.amountToWithdraw")}
</span>
<div className="flex items-center gap-1 sm:hidden">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{parseFloat(truncateDecimals(lpBalance, withdrawDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: withdrawDisplayDecimals })} YT LP
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<button onClick={() => handleWithdrawPercentage(25)} className={pctBtnClass}>25%</button>
<button onClick={() => handleWithdrawPercentage(50)} className={pctBtnClass}>50%</button>
<button onClick={() => handleWithdrawPercentage(75)} className={pctBtnClass}>75%</button>
<button onClick={() => handleWithdrawPercentage(100)} className={pctBtnClass}>{t("mintSwap.max")}</button>
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} className="ml-1 hidden sm:block" />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400 hidden sm:block">
{parseFloat(truncateDecimals(lpBalance, withdrawDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: withdrawDisplayDecimals })} YT LP
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start flex-1">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={withdrawAmount}
onChange={(e) => handleWithdrawAmountChange(e.target.value)}
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{withdrawAmount ? `${withdrawAmount} LP` : "--"}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<TokenSelector
selectedToken={outputTokenObj}
onSelect={handleOutputTokenSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol={outputToken}
/>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
{!mounted ? `0 ${outputToken}` : `${parseFloat(truncateDecimals(outputTokenBalance, 6)).toLocaleString('en-US', { maximumFractionDigits: 6 })} ${outputToken}`}
</span>
</div>
</div>
</div>
</div>
{/* Withdraw Info */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400">
{t("alp.youWillReceive")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400">
{estAmountOut != null
? `${estAmountOut.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${outputToken}`
: withdrawAmount ? (isWithdrawEstLoading ? '...' : '--') : '--'}
</span>
</div>
</div>
</div>
</Tab>
</Tabs>
{/* Submit Button */}
{(() => {
const amountInvalid = !!currentAmount && !isValidAmount(currentAmount);
return (
<Button
isDisabled={!mounted || !isConnected || !isValidAmount(currentAmount) || isLoading || (activeTab === 'withdraw' && withdrawCooldownLeft > 0)}
color="default"
variant="solid"
className={buttonStyles({ intent: "theme" })}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={activeTab === 'deposit' ? handleDeposit : handleWithdraw}
>
{!mounted && t("common.connectWallet")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && currentStatus === 'idle' && amountInvalid && t("common.invalidAmount")}
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'deposit' && t("alp.addLiquidity")}
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'withdraw' && withdrawCooldownLeft > 0 && `15-min Withdrawal Lock (${String(Math.floor(withdrawCooldownLeft / 60)).padStart(2, '0')}:${String(withdrawCooldownLeft % 60).padStart(2, '0')})`}
{mounted && isConnected && currentStatus === 'idle' && !amountInvalid && activeTab === 'withdraw' && withdrawCooldownLeft === 0 && t("alp.removeLiquidity")}
{mounted && currentStatus === 'approving' && t("common.approving")}
{mounted && currentStatus === 'approved' && (activeTab === 'deposit' ? t("alp.approvedAdding") : t("alp.approvedRemoving"))}
{mounted && (currentStatus === 'depositing' || currentStatus === 'withdrawing') && (activeTab === 'deposit' ? t("alp.adding") : t("alp.removing"))}
{mounted && currentStatus === 'success' && t("common.success")}
{mounted && currentStatus === 'error' && t("common.failed")}
</Button>
);
})()}
{/* Info Note */}
<div className="text-center">
<span className="text-caption-tiny font-regular text-[#9ca1af] dark:text-gray-400">
{activeTab === 'deposit' ? t("alp.depositNote") : t("alp.withdrawNote")}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useApp } from "@/contexts/AppContext";
import * as echarts from "echarts";
interface HistoryPoint {
time: string;
ts: number;
poolAPR: number;
alpPrice: number;
feeSurplus: number;
poolValue: number;
}
export default function PriceHistoryCard() {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<"price" | "apr">("price");
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { data: history = [], isLoading } = useQuery<HistoryPoint[]>({
queryKey: ["alp-history"],
queryFn: async () => {
const res = await fetch("/api/alp/history?days=30");
const json = await res.json();
return json.data ?? [];
},
refetchInterval: 5 * 60 * 1000,
});
const isEmpty = history.length < 2;
const labels = isEmpty ? [] : history.map(p => p.time);
const priceData = isEmpty ? [] : history.map(p => p.alpPrice);
const aprData = isEmpty ? [] : history.map(p => p.poolAPR);
const activeData = activeTab === "price" ? priceData : aprData;
const activeColor = activeTab === "price" ? "#10b981" : "#1447e6";
const areaColor0 = activeTab === "price" ? "rgba(16,185,129,0.3)" : "rgba(20,71,230,0.25)";
const areaColor1 = activeTab === "price" ? "rgba(16,185,129,0)" : "rgba(20,71,230,0)";
const suffix = activeTab === "price" ? " USDC" : "%";
const precision = activeTab === "price" ? 4 : 4;
const highest = activeData.length > 0 ? Math.max(...activeData) : 0;
const lowest = activeData.length > 0 ? Math.min(...activeData) : 0;
const current = activeData.length > 0 ? activeData[activeData.length - 1] : 0;
// Avg APR from feeSurplus / poolValue delta
const actualDays = history.length >= 2
? (history[history.length - 1].ts - history[0].ts) / 86400
: 0;
const avgAPR = (() => {
if (history.length < 2 || actualDays <= 0) return 0;
const first = history[0];
const last = history[history.length - 1];
const surplus = (last.feeSurplus ?? 0) - (first.feeSurplus ?? 0);
const pv = last.poolValue ?? 0;
if (pv <= 0 || surplus <= 0) return 0;
return surplus / pv / actualDays * 365 * 100;
})();
const periodLabel = actualDays >= 1
? `Last ${Math.round(actualDays)} days`
: actualDays > 0
? `Last ${Math.round(actualDays * 24)}h`
: "Last 30 days";
const fmt = (v: number) =>
activeTab === "price" ? `$${v.toFixed(precision)}` : `${v.toFixed(precision)}%`;
const updateChart = () => {
if (!chartInstance.current) return;
// 自适应 Y 轴:以数据范围为基础,上下各留 20% 的 padding
const yMin = activeData.length > 0 ? Math.min(...activeData) : 0;
const yMax = activeData.length > 0 ? Math.max(...activeData) : 1;
const range = yMax - yMin || yMax * 0.01 || 0.01;
const yAxisMin = yMin - range * 0.2;
const yAxisMax = yMax + range * 0.2;
chartInstance.current.setOption({
grid: { left: 0, right: 0, top: 10, bottom: 24 },
tooltip: {
trigger: "axis",
confine: true,
backgroundColor: "rgba(17,24,39,0.9)",
borderColor: "#374151",
textStyle: { color: "#f9fafb", fontSize: 12 },
formatter: (params: any) => {
const d = params[0];
const val = Number(d.value).toFixed(precision);
const display = activeTab === "price" ? `$${val}` : `${val}%`;
return `<div style="padding:4px 8px"><span style="color:#9ca3af;font-size:11px">${d.name}</span><br/><span style="color:${activeColor};font-weight:600;font-size:14px">${display}</span></div>`;
},
},
xAxis: {
type: "category",
data: labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: true,
color: "#9ca3af",
fontSize: 11,
interval: Math.max(0, Math.floor(labels.length / 6) - 1),
},
boundaryGap: false,
},
yAxis: {
type: "value",
min: yAxisMin,
max: yAxisMax,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
series: [{
data: activeData,
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 5,
lineStyle: { color: activeColor, width: 2 },
itemStyle: { color: activeColor },
areaStyle: {
color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: areaColor0 },
{ offset: 1, color: areaColor1 },
],
},
},
}],
}, true);
};
useEffect(() => {
const frame = requestAnimationFrame(() => {
if (!chartRef.current) return;
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
updateChart();
chartInstance.current?.resize();
});
const handleResize = () => chartInstance.current?.resize();
window.addEventListener("resize", handleResize);
return () => { cancelAnimationFrame(frame); window.removeEventListener("resize", handleResize); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, history]);
useEffect(() => {
return () => { chartInstance.current?.dispose(); };
}, []);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 px-6 py-6 flex flex-col gap-0 h-full">
{/* Tabs */}
<div className="flex gap-6 border-b border-border-gray dark:border-gray-700 mb-6">
<button
onClick={() => setActiveTab("price")}
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
activeTab === "price"
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("alp.priceHistory")}
</button>
<button
onClick={() => setActiveTab("apr")}
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
activeTab === "apr"
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("alp.aprHistory")}
</button>
</div>
<div className="flex flex-col gap-6">
{/* Period label + avg for APR */}
<div className="flex items-center justify-between">
<div className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{periodLabel}
</div>
{activeTab === "apr" && (
<div className="text-caption-tiny font-bold text-[#1447e6]">
Avg APR: {avgAPR.toFixed(4)}%
</div>
)}
</div>
{/* Chart */}
<div className="relative w-full border-b border-border-gray dark:border-gray-600" style={{ height: "260px" }}>
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm text-text-tertiary dark:text-gray-400">{t("common.loading")}</span>
</div>
) : isEmpty ? (
<div className="w-full h-full flex items-center justify-center text-sm text-text-tertiary dark:text-gray-500">
{t("common.noData")}
</div>
) : (
<div ref={chartRef} className="w-full h-full" />
)}
</div>
{/* Stats */}
<div className="flex flex-col gap-3">
{[
{ label: t("alp.highest"), value: isEmpty ? "--" : fmt(highest) },
{ label: t("alp.lowest"), value: isEmpty ? "--" : fmt(lowest) },
{ label: t("alp.current"), value: isEmpty ? "--" : fmt(current) },
].map(({ label, value }) => (
<div key={label} className="flex items-start justify-between">
<div className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">{label}</div>
<div className="text-caption-tiny font-bold text-text-primary dark:text-white tabular-nums">{value}</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { Button, ButtonProps } from "@heroui/react";
import { tv } from "tailwind-variants";
const borderedButtonStyles = tv({
slots: {
base: "rounded-xl border border-[#E5E7EB] dark:border-gray-600 flex items-center justify-center",
button: "w-full h-full rounded-xl px-6 text-body-small font-bold border-none",
},
variants: {
size: {
md: {
base: "h-10",
},
lg: {
base: "h-11",
},
},
fullWidth: {
true: {
base: "w-full",
},
},
isTheme: {
true: {
button: "bg-white dark:bg-gray-800 text-text-primary dark:text-white",
},
},
},
defaultVariants: {
size: "md",
isTheme: false,
},
});
interface BorderedButtonProps extends ButtonProps {
size?: "md" | "lg";
fullWidth?: boolean;
isTheme?: boolean;
}
export default function BorderedButton({
size = "md",
fullWidth = false,
isTheme = false,
className,
...props
}: BorderedButtonProps) {
const { base, button } = borderedButtonStyles({ size, fullWidth, isTheme });
return (
<div className={base({ className })}>
<Button
className={button()}
{...props}
>
{props.children}
</Button>
</div>
);
}

View File

@@ -0,0 +1,429 @@
"use client";
import Image from "next/image";
import { Modal, ModalContent, Button } from "@heroui/react";
import { buttonStyles } from "@/lib/buttonStyles";
import { USDC_TO_GYUS_RATE, USDC_USD_RATE } from "@/lib/constants";
interface ReviewModalProps {
isOpen: boolean;
onClose: () => void;
amount: string;
productName?: string;
}
export default function ReviewModal({
isOpen,
onClose,
amount,
productName = "High-Yield US Equity",
}: ReviewModalProps) {
const gyusAmount = amount ? (parseFloat(amount) * USDC_TO_GYUS_RATE).toFixed(0) : "0";
const usdValue = amount ? (parseFloat(amount) * USDC_USD_RATE).toFixed(2) : "0.00";
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="lg"
classNames={{
base: "bg-transparent shadow-none",
backdrop: "bg-black/50",
}}
>
<ModalContent>
<div
style={{
background: "#ffffff",
borderRadius: "24px",
padding: "24px",
display: "flex",
flexDirection: "column",
gap: "24px",
position: "relative",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "446px",
}}
>
<div
style={{
color: "#111827",
fontSize: "20px",
fontWeight: 700,
lineHeight: "140%",
fontFamily: "var(--font-inter)",
}}
>
Review
</div>
<button
onClick={onClose}
style={{
width: "24px",
height: "24px",
cursor: "pointer",
background: "none",
border: "none",
padding: 0,
}}
>
<Image
src="/icons/ui/vuesax-linear-close-circle1.svg"
alt="Close"
width={24}
height={24}
/>
</button>
</div>
{/* Product Info */}
<div
style={{
background: "#f9fafb",
borderRadius: "12px",
padding: "16px",
display: "flex",
flexDirection: "row",
gap: "12px",
alignItems: "center",
}}
>
<div
style={{
background: "#fff7ed",
borderRadius: "6px",
border: "0.75px solid rgba(0, 0, 0, 0.1)",
width: "40px",
height: "30px",
boxShadow: "inset 0px 1.5px 3px 0px rgba(0, 0, 0, 0.05)",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Image
src="/assets/flags/lr0.svg"
alt="Product"
width={43}
height={30}
style={{ width: "107.69%", height: "100%", objectFit: "cover" }}
/>
</div>
<div
style={{
color: "#111827",
fontSize: "18px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{productName}
</div>
</div>
{/* Amount Section */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "16px",
width: "446px",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
alignItems: "center",
}}
>
<div
style={{
color: "#4b5563",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Deposit
</div>
<div style={{ textAlign: "center" }}>
<span
style={{
color: "#111827",
fontSize: "48px",
fontWeight: 700,
lineHeight: "120%",
letterSpacing: "-0.01em",
fontFamily: "var(--font-inter)",
}}
>
{amount || "0"}
</span>
<span
style={{
color: "#9ca1af",
fontSize: "20px",
fontWeight: 700,
lineHeight: "140%",
fontFamily: "var(--font-inter)",
marginLeft: "8px",
}}
>
USDC
</span>
</div>
<div
style={{
color: "#4b5563",
fontSize: "18px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
${usdValue}
</div>
</div>
</div>
{/* Transaction Details */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "16px",
width: "446px",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0px",
}}
>
{/* Deposit Row */}
<div
style={{
borderRadius: "16px",
padding: "16px",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "12px",
alignItems: "center",
}}
>
<Image src="/components/common/icon0.svg" alt="" width={20} height={20} />
<div
style={{
color: "#4b5563",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Deposit (USDC)
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
}}
>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{amount ? parseFloat(amount).toLocaleString() : "0"}
</div>
<Image src="/components/common/icon3.svg" alt="" width={14} height={14} />
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
0
</div>
</div>
</div>
{/* Get GYUS Row */}
<div
style={{
borderRadius: "16px",
padding: "16px",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
height: "54px",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "12px",
alignItems: "center",
}}
>
<Image src="/components/common/icon2.svg" alt="" width={20} height={20} />
<div
style={{
color: "#4b5563",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
GET(GYUS)
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
0.00
</div>
<Image src="/components/common/icon3.svg" alt="" width={14} height={14} />
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{gyusAmount}
</div>
</div>
</div>
</div>
{/* APY Info */}
<div
style={{
background: "#f2fcf7",
borderRadius: "16px",
border: "1px solid #cef3e0",
padding: "16px",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
}}
>
<Image src="/components/common/icon4.svg" alt="" width={20} height={20} />
<div
style={{
color: "#10b981",
fontSize: "14px",
fontWeight: 600,
lineHeight: "20px",
letterSpacing: "-0.15px",
fontFamily: "var(--font-inter)",
}}
>
APY
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
gap: "4px",
alignItems: "center",
}}
>
<div
style={{
color: "#10b981",
fontSize: "18px",
fontWeight: 700,
lineHeight: "28px",
letterSpacing: "-0.44px",
fontFamily: "var(--font-inter)",
}}
>
22.0%
</div>
<Image src="/components/common/icon5.svg" alt="" width={16} height={16} />
</div>
</div>
</div>
{/* Confirm Button */}
<Button
color="default"
variant="solid"
className={buttonStyles({ intent: "theme" })}
onPress={() => {
onClose();
}}
>
Confirm Transaction
</Button>
</div>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect } from "react";
type ToastType = "success" | "error" | "info" | "warning";
interface ToastProps {
message: string;
type: ToastType;
isOpen: boolean;
onClose: () => void;
duration?: number;
}
export default function Toast({
message,
type,
isOpen,
onClose,
duration = 3000,
}: ToastProps) {
useEffect(() => {
if (isOpen && duration > 0) {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [isOpen, duration, onClose]);
if (!isOpen) return null;
const colors = {
success: "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700 text-green-800 dark:text-green-200",
error: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700 text-red-800 dark:text-red-200",
info: "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700 text-blue-800 dark:text-blue-200",
warning: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700 text-yellow-800 dark:text-yellow-200",
};
const icons = {
success: "✓",
error: "✕",
info: "",
warning: "⚠",
};
return (
<div className="fixed top-4 right-4 z-50 animate-in slide-in-from-top-2 fade-in duration-300">
<div className={`${colors[type]} border rounded-xl px-4 py-3 shadow-lg flex items-center gap-3 min-w-[300px] max-w-[400px]`}>
<span className="text-lg">{icons[type]}</span>
<span className="text-sm font-medium flex-1">{message}</span>
<button
onClick={onClose}
className="text-current opacity-50 hover:opacity-100 transition-opacity"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { useEffect, useRef } from "react";
import Image from "next/image";
import {
Dropdown,
DropdownTrigger,
DropdownMenu,
DropdownItem,
} from "@heroui/react";
import { useTokenList } from "@/hooks/useTokenList";
import { Token } from "@/lib/api/tokens";
interface TokenSelectorProps {
selectedToken?: Token;
onSelect: (token: Token) => void;
filterTypes?: ('stablecoin' | 'yield-token')[];
disabled?: boolean;
defaultSymbol?: string;
}
export default function TokenSelector({
selectedToken,
onSelect,
filterTypes = ['stablecoin', 'yield-token'],
disabled = false,
defaultSymbol,
}: TokenSelectorProps) {
const hasInitialized = useRef(false);
const { tokens: allTokens, isLoading } = useTokenList();
const tokens = allTokens.filter(t => filterTypes.includes(t.tokenType));
// 数据加载完成后自动选中默认 token仅初始化一次
useEffect(() => {
if (hasInitialized.current || selectedToken || tokens.length === 0) return;
hasInitialized.current = true;
const target = defaultSymbol
? (tokens.find(t => t.symbol === defaultSymbol) ?? tokens[0])
: tokens[0];
onSelect(target);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tokens]);
if (isLoading) {
return (
<button className="bg-white dark:bg-gray-700 rounded-full border border-gray-400 dark:border-gray-500 px-4 h-[46px] flex items-center gap-2 opacity-50 cursor-not-allowed">
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-600 animate-pulse" />
<span className="text-body-default font-bold text-text-primary dark:text-white">
Loading...
</span>
</button>
);
}
return (
<Dropdown shouldBlockScroll={false}>
<DropdownTrigger>
<button
disabled={disabled}
className={`bg-white dark:bg-gray-700 rounded-full border border-gray-400 dark:border-gray-500 px-4 h-[46px] flex items-center justify-between gap-2 outline-none focus:outline-none transition-all duration-200 ease-in-out ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary dark:hover:border-primary-dark hover:shadow-md'
}`}
>
{selectedToken ? (
<>
<div className="flex items-center gap-2">
<Image
src={selectedToken.iconUrl || '/assets/tokens/default.svg'}
alt={selectedToken.symbol}
width={32}
height={32}
className="rounded-full transition-opacity duration-150"
priority
onError={(e) => {
(e.target as HTMLImageElement).src = '/assets/tokens/default.svg';
}}
/>
<span className="text-body-default font-bold text-text-primary dark:text-white transition-opacity duration-150">
{selectedToken.symbol}
</span>
</div>
<svg
className="w-4 h-4 text-text-tertiary dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</>
) : (
<span className="text-body-default text-text-tertiary dark:text-gray-400">
No tokens available
</span>
)}
</button>
</DropdownTrigger>
<DropdownMenu
aria-label="Token selection"
onAction={(key) => {
const token = tokens.find(t => t.symbol === key);
if (token) onSelect(token);
}}
>
{tokens.map((token) => (
<DropdownItem
key={token.symbol}
className="hover:bg-bg-subtle dark:hover:bg-gray-700 rounded-lg"
textValue={token.symbol}
>
<div className="flex items-center gap-3 py-1">
<Image
src={token.iconUrl || '/assets/tokens/default.svg'}
alt={token.symbol || 'token'}
width={32}
height={32}
className="rounded-full"
onError={(e) => {
(e.target as HTMLImageElement).src = '/assets/tokens/default.svg';
}}
/>
<div className="flex flex-col">
<span className="text-body-default font-bold text-text-primary dark:text-white">
{token.symbol}
</span>
<span className="text-caption-tiny text-text-tertiary dark:text-gray-400">
{token.name}
</span>
</div>
</div>
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
);
}

View File

@@ -0,0 +1,500 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount, useReadContract, useGasPrice } from 'wagmi';
import { formatUnits, parseUnits } from 'viem';
import { useTokenBalance } from '@/hooks/useBalance';
import { useSwap } from '@/hooks/useSwap';
import { abis, getContractAddress } from '@/lib/contracts';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import Toast from "@/components/common/Toast";
import TokenSelector from '@/components/common/TokenSelector';
import { Token } from '@/lib/api/tokens';
interface TradePanelProps {
showHeader?: boolean;
title?: string;
subtitle?: string;
}
export default function TradePanel({
showHeader = false,
title = "",
subtitle = "",
}: TradePanelProps) {
const { t } = useApp();
const [sellAmount, setSellAmount] = useState<string>("");
const [buyAmount, setBuyAmount] = useState<string>("");
const [mounted, setMounted] = useState(false);
const [tokenIn, setTokenIn] = useState<string>('');
const [tokenInObj, setTokenInObj] = useState<Token | undefined>();
const [tokenOut, setTokenOut] = useState<string>('');
const [tokenOutObj, setTokenOutObj] = useState<Token | undefined>();
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" | "warning" } | null>(null);
const [slippage, setSlippage] = useState<number>(0.5);
const SLIPPAGE_OPTIONS = [0.3, 0.5, 1, 3];
useEffect(() => {
setMounted(true);
}, []);
const { address, isConnected, chainId } = useAccount();
// 从合约地址读取精度tokenIn / tokenOut
const sellInputDecimals = useTokenDecimalsFromAddress(
tokenInObj?.contractAddress,
tokenInObj?.decimals ?? 18
);
const buyInputDecimals = useTokenDecimalsFromAddress(
tokenOutObj?.contractAddress,
tokenOutObj?.decimals ?? 18
);
const sellDisplayDecimals = Math.min(sellInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidSellAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleSellAmountChange = (value: string) => {
if (value === '') { setSellAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > sellDisplayDecimals) return;
const maxBalance = truncateDecimals(tokenInBalance, sellDisplayDecimals);
if (parseFloat(value) > parseFloat(maxBalance)) { setSellAmount(maxBalance); return; }
setSellAmount(value);
};
// 余额:通过合约地址动态查询
const { formattedBalance: tokenInBalance, isLoading: isBalanceLoading, refetch: refetchTokenIn } = useTokenBalance(
tokenInObj?.contractAddress,
sellInputDecimals
);
const { formattedBalance: tokenOutBalance, refetch: refetchTokenOut } = useTokenBalance(
tokenOutObj?.contractAddress,
buyInputDecimals
);
// 查询池子流动性YTVault.usdyAmounts(tokenOutAddress)
const vaultAddress = chainId ? getContractAddress('YTVault', chainId) : undefined;
const tokenInAddress = tokenInObj?.contractAddress;
const tokenOutAddress = tokenOutObj?.contractAddress;
const { data: poolLiquidityRaw, refetch: refetchPool } = useReadContract({
address: vaultAddress,
abi: abis.YTVault,
functionName: 'usdyAmounts',
args: tokenOutAddress ? [tokenOutAddress as `0x${string}`] : undefined,
query: {
enabled: !!vaultAddress && !!tokenOutAddress,
}
});
const poolLiquidityOut = poolLiquidityRaw ? formatUnits(poolLiquidityRaw as bigint, buyInputDecimals) : '0';
// 查询实际兑换数量getSwapAmountOut(tokenIn, tokenOut, amountIn)
const sellAmountWei = (() => {
if (!sellAmount || !isValidSellAmount(sellAmount)) return undefined;
try { return parseUnits(sellAmount, sellInputDecimals); } catch { return undefined; }
})();
const { data: swapAmountOutRaw } = useReadContract({
address: vaultAddress,
abi: abis.YTVault,
functionName: 'getSwapAmountOut',
args: tokenInAddress && tokenOutAddress && sellAmountWei !== undefined
? [tokenInAddress as `0x${string}`, tokenOutAddress as `0x${string}`, sellAmountWei]
: undefined,
query: {
enabled: !!vaultAddress && !!tokenInAddress && !!tokenOutAddress && sellAmountWei !== undefined && tokenIn !== tokenOut,
},
});
// 返回 [amountOut, amountOutAfterFees, feeBasisPoints]
const swapRaw = swapAmountOutRaw as [bigint, bigint, bigint] | undefined;
const swapAmountOut = swapRaw ? formatUnits(swapRaw[1], buyInputDecimals) : '';
const swapFeeBps = swapRaw ? Number(swapRaw[2]) : 0;
// 读取 vault 基础 swap fee (bps)
const { data: baseFeeRaw } = useReadContract({
address: vaultAddress,
abi: abis.YTVault,
functionName: 'swapFeeBasisPoints',
query: { enabled: !!vaultAddress },
});
const baseBps = baseFeeRaw ? Number(baseFeeRaw as bigint) : 0;
// Gas price for network cost estimate
const { data: gasPriceWei } = useGasPrice({ chainId });
const GAS_ESTIMATE = 500_000n;
const networkCostWei = gasPriceWei ? GAS_ESTIMATE * gasPriceWei : 0n;
const networkCostNative = networkCostWei > 0n ? parseFloat(formatUnits(networkCostWei, 18)) : 0;
const nativeToken = chainId === 97 ? 'BNB' : 'ETH';
// Fee & Price Impact 计算
const feeAmountBigInt = swapRaw ? swapRaw[0] - swapRaw[1] : 0n;
const feeAmountFormatted = feeAmountBigInt > 0n ? truncateDecimals(formatUnits(feeAmountBigInt, buyInputDecimals), 6) : '0';
const feePercent = swapFeeBps / 100;
const impactBps = Math.max(0, swapFeeBps - baseBps);
const impactPercent = impactBps / 100;
const {
status: swapStatus,
error: swapError,
isLoading: isSwapLoading,
executeApproveAndSwap,
reset: resetSwap,
} = useSwap();
// 买入数量由合约 getSwapAmountOut 决定
useEffect(() => {
if (swapAmountOut && isValidSellAmount(sellAmount)) {
setBuyAmount(truncateDecimals(swapAmountOut, 6));
} else {
setBuyAmount("");
}
}, [swapAmountOut, sellAmount]);
// 交易成功后刷新余额
useEffect(() => {
if (swapStatus === 'success') {
setToast({ message: `${t('swap.successMsg')} ${tokenIn} ${t('swap.to')} ${tokenOut}!`, type: "success" });
refetchTokenIn();
refetchTokenOut();
refetchPool();
setTimeout(() => {
resetSwap();
setSellAmount("");
setBuyAmount("");
}, 3000);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapStatus]);
// 显示错误提示
useEffect(() => {
if (swapError) {
setToast({ message: swapError, type: swapError === 'Transaction cancelled' ? "warning" : "error" });
refetchTokenIn();
refetchTokenOut();
refetchPool();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [swapError]);
// 处理 tokenIn 选择
const handleTokenInSelect = useCallback((token: Token) => {
if (tokenInObj && token.symbol === tokenIn) return;
if (token.symbol === tokenOut) {
setTokenOut(tokenIn);
setTokenOutObj(tokenInObj);
}
setTokenInObj(token);
setTokenIn(token.symbol);
setSellAmount("");
setBuyAmount("");
}, [tokenIn, tokenOut, tokenInObj]);
// 处理 tokenOut 选择
const handleTokenOutSelect = useCallback((token: Token) => {
if (tokenOutObj && token.symbol === tokenOut) return;
if (token.symbol === tokenIn) {
setTokenIn(tokenOut);
setTokenInObj(tokenOutObj);
}
setTokenOutObj(token);
setTokenOut(token.symbol);
setSellAmount("");
setBuyAmount("");
}, [tokenIn, tokenOut, tokenOutObj]);
// 切换交易方向
const handleSwapDirection = () => {
const tempSymbol = tokenIn;
const tempObj = tokenInObj;
setTokenIn(tokenOut);
setTokenInObj(tokenOutObj);
setTokenOut(tempSymbol);
setTokenOutObj(tempObj);
setSellAmount("");
setBuyAmount("");
};
// 百分比按钮处理
const handlePercentage = (percentage: number) => {
const balance = parseFloat(tokenInBalance);
if (balance > 0) {
const raw = (balance * percentage / 100).toString();
setSellAmount(truncateDecimals(raw, sellDisplayDecimals));
}
};
return (
<div className={`flex flex-col gap-6 ${showHeader ? "w-full md:w-full md:max-w-[600px]" : "w-full"}`}>
{/* Header Section - Optional */}
{showHeader && (
<div className="flex flex-col gap-2">
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white text-center">
{title}
</h1>
<p className="text-body-default font-medium text-text-secondary dark:text-gray-400 text-center">
{subtitle}
</p>
</div>
)}
{/* Trade Panel */}
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6">
<div className="flex flex-col gap-4">
{/* SELL and BUY Container with Exchange Icon */}
<div className="flex flex-col gap-2 relative">
{/* SELL Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-4">
{/* Label and Buttons */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-body-small font-bold text-text-secondary dark:text-gray-300">
{t("alp.sell")}
</span>
<div className="flex items-center gap-1.5">
{[25, 50, 75].map(pct => (
<button
key={pct}
onClick={() => handlePercentage(pct)}
className="flex-1 sm:flex-none bg-[#e5e7eb] dark:bg-gray-600 rounded-full px-2 sm:px-3 h-[22px] text-[10px] font-medium text-text-primary dark:text-white border-none hover:bg-[#d1d5db] dark:hover:bg-gray-500 transition-colors"
>
{pct}%
</button>
))}
<button
onClick={() => handlePercentage(100)}
className="flex-1 sm:flex-none bg-[#e5e7eb] dark:bg-gray-600 rounded-full px-2 sm:px-3 h-[22px] text-[10px] font-medium text-text-primary dark:text-white border-none hover:bg-[#d1d5db] dark:hover:bg-gray-500 transition-colors"
>
{t("mintSwap.max")}
</button>
</div>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-2 min-h-[60px] md:h-[70px]">
<div className="flex flex-col items-start justify-between flex-1 h-full">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={sellAmount}
onChange={(e) => handleSellAmountChange(e.target.value)}
className="w-full text-left text-2xl md:text-[32px] font-bold leading-[130%] tracking-[-0.01em] text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-body-small font-medium text-text-tertiary dark:text-gray-400">
{sellAmount ? `$${sellAmount}` : '--'}
</span>
</div>
<div className="flex flex-col items-end justify-between gap-1 h-full">
<TokenSelector
selectedToken={tokenInObj}
onSelect={handleTokenInSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol="YT-A"
/>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
{!mounted ? `0 ${tokenIn}` : (isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(tokenInBalance, sellDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: sellDisplayDecimals })} ${tokenIn}`)}
</span>
</div>
</div>
</div>
{/* Exchange Icon */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10">
<button
onClick={handleSwapDirection}
className="bg-bg-surface dark:bg-gray-700 rounded-full w-10 h-10 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors cursor-pointer"
style={{
boxShadow: "0px 1px 2px -1px rgba(0, 0, 0, 0.1), 0px 1px 3px 0px rgba(0, 0, 0, 0.1)",
}}
>
<Image
src="/components/common/icon-swap-diagonal.svg"
alt="Exchange"
width={18}
height={18}
/>
</button>
</div>
{/* BUY Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-4">
{/* Label */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-body-small font-bold text-text-secondary dark:text-gray-300">
{t("alp.buy")}
</span>
<div className="flex items-center gap-1">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-400">
{!mounted ? `0 ${tokenOut}` : `${parseFloat(tokenOutBalance).toLocaleString()} ${tokenOut}`}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-1">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-500">
{t('swap.pool')}: {!mounted ? '...' : `${parseFloat(poolLiquidityOut).toLocaleString()} ${tokenOut}`}
</span>
</div>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-2 min-h-[60px] md:h-[70px]">
<div className="flex flex-col items-start justify-between flex-1 h-full">
<input
type="text"
placeholder="0.00"
value={buyAmount}
readOnly
className="w-full text-left text-2xl md:text-[32px] font-bold leading-[130%] tracking-[-0.01em] text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none cursor-default"
/>
<span className="text-body-small font-medium text-text-tertiary dark:text-gray-400">
{buyAmount ? `$${buyAmount}` : '--'}
</span>
</div>
<div className="flex flex-col items-end justify-between gap-1 h-full">
<TokenSelector
selectedToken={tokenOutObj}
onSelect={handleTokenOutSelect}
filterTypes={['stablecoin', 'yield-token']}
defaultSymbol="YT-B"
/>
</div>
</div>
</div>
</div>
{/* Slippage Tolerance */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between px-1">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-400">
{t("swap.slippageTolerance")}
</span>
<div className="flex items-center gap-1">
{SLIPPAGE_OPTIONS.map((opt) => (
<button
key={opt}
onClick={() => setSlippage(opt)}
className={`flex-1 sm:flex-none px-2 sm:px-3 h-7 rounded-full text-[12px] font-semibold transition-colors ${
slippage === opt
? "bg-foreground text-background"
: "bg-[#e5e7eb] dark:bg-gray-600 text-text-primary dark:text-white hover:bg-[#d1d5db] dark:hover:bg-gray-500"
}`}
>
{opt}%
</button>
))}
</div>
</div>
{/* Submit Button */}
{(() => {
const inputAmt = parseFloat(sellAmount) || 0;
const balance = parseFloat(tokenInBalance) || 0;
const poolAmt = parseFloat(poolLiquidityOut) || 0;
const insufficientBalance = inputAmt > 0 && inputAmt > balance;
const insufficientPool = inputAmt > 0 && poolAmt > 0 && inputAmt > poolAmt;
const buttonDisabled = !mounted || !isConnected || !isValidSellAmount(sellAmount) || isSwapLoading || tokenIn === tokenOut || insufficientBalance || insufficientPool || !tokenInObj || !tokenOutObj;
const buttonLabel = (() => {
if (!mounted || !isConnected) return t('common.connectWallet');
if (swapStatus === 'idle' && !!sellAmount && !isValidSellAmount(sellAmount)) return t('common.invalidAmount');
if (insufficientBalance) return t('supply.insufficientBalance');
if (insufficientPool) return t('swap.insufficientLiquidity');
if (swapStatus === 'approving') return t('common.approving');
if (swapStatus === 'approved') return t('swap.approved');
if (swapStatus === 'swapping') return t('swap.swapping');
if (swapStatus === 'success') return t('common.success');
if (swapStatus === 'error') return t('common.failed');
return `${t('swap.swapBtn')} ${tokenIn} ${t('swap.to')} ${tokenOut}`;
})();
const minOut = swapAmountOut && isValidSellAmount(sellAmount)
? (parseFloat(swapAmountOut) * (1 - slippage / 100)).toFixed(buyInputDecimals)
: '0';
return (
<Button
isDisabled={buttonDisabled}
color="default"
className={buttonStyles({ intent: "theme" })}
onPress={() => {
if (!insufficientBalance && !insufficientPool && isValidSellAmount(sellAmount) && tokenIn !== tokenOut && tokenInObj && tokenOutObj) {
setToast(null);
resetSwap();
executeApproveAndSwap(
tokenInObj.contractAddress,
sellInputDecimals,
tokenOutObj.contractAddress,
buyInputDecimals,
sellAmount,
minOut
);
}
}}
>
{buttonLabel}
</Button>
);
})()}
{/* Rate & Info Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 px-4 py-3 flex flex-col gap-2">
{/* Exchange Rate */}
<div className="flex items-center justify-between">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-300">
{swapAmountOut && isValidSellAmount(sellAmount)
? `1 ${tokenIn}${truncateDecimals((parseFloat(swapAmountOut) / parseFloat(sellAmount)).toString(), 6)} ${tokenOut}`
: `1 ${tokenIn} = -- ${tokenOut}`}
</span>
</div>
{/* Divider */}
<div className="border-t border-border-gray dark:border-gray-600" />
{/* Network Cost */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">{t('swap.networkCost')}</span>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300">
{networkCostNative > 0 ? `~${networkCostNative.toFixed(6)} ${nativeToken}` : '--'}
</span>
</div>
{/* Max Slippage */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">{t('swap.maxSlippage')}</span>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300">{slippage}%</span>
</div>
{/* Price Impact */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">{t('swap.priceImpact')}</span>
<span className={`text-caption-tiny font-medium ${
isValidSellAmount(sellAmount) && swapAmountOut
? impactPercent > 1 ? 'text-red-500' : impactPercent > 0.3 ? 'text-amber-500' : 'text-text-secondary dark:text-gray-300'
: 'text-text-secondary dark:text-gray-300'
}`}>
{isValidSellAmount(sellAmount) && swapAmountOut ? `${impactPercent.toFixed(2)}%` : '--'}
</span>
</div>
{/* Fee */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">{t('swap.fee')}</span>
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300">
{swapFeeBps > 0 && isValidSellAmount(sellAmount) && swapAmountOut
? `${feePercent.toFixed(2)}% (≈${feeAmountFormatted} ${tokenOut})`
: '--'}
</span>
</div>
</div>
</div>
</div>
{/* Toast Notification */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
isOpen={!!toast}
onClose={() => setToast(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,566 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Modal, ModalContent, Button } from "@heroui/react";
import { buttonStyles } from "@/lib/buttonStyles";
import { USDC_TO_ALP_RATE, USDC_USD_RATE, WITHDRAW_FEE_RATE, SLIPPAGE_PROTECTION } from "@/lib/constants";
interface WithdrawModalProps {
isOpen: boolean;
onClose: () => void;
amount: string;
}
export default function WithdrawModal({
isOpen,
onClose,
amount,
}: WithdrawModalProps) {
const [showDetails, setShowDetails] = useState(true);
const alpAmount = amount ? (parseFloat(amount) * USDC_TO_ALP_RATE).toFixed(0) : "0";
const usdValue = amount ? (parseFloat(amount) * USDC_USD_RATE).toFixed(2) : "0.00";
const fee = amount ? (parseFloat(amount) * WITHDRAW_FEE_RATE).toFixed(2) : "0.00";
const minReceive = amount ? (parseFloat(amount) * USDC_TO_ALP_RATE * SLIPPAGE_PROTECTION).toFixed(0) : "0";
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="lg"
classNames={{
base: "bg-transparent shadow-none",
backdrop: "bg-black/50",
}}
>
<ModalContent>
<div
style={{
background: "#ffffff",
borderRadius: "24px",
padding: "24px",
display: "flex",
flexDirection: "column",
gap: "24px",
position: "relative",
overflow: "hidden",
alignItems: "center",
}}
>
{/* Header */}
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "446px",
}}
>
<div
style={{
color: "#111827",
fontSize: "20px",
fontWeight: 700,
lineHeight: "140%",
fontFamily: "var(--font-inter)",
}}
>
Review
</div>
<button
onClick={onClose}
style={{
width: "24px",
height: "24px",
cursor: "pointer",
background: "none",
border: "none",
padding: 0,
}}
>
<Image
src="/icons/ui/vuesax-linear-close-circle1.svg"
alt="Close"
width={24}
height={24}
/>
</button>
</div>
{/* Exchange Section */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
alignItems: "center",
}}
>
<div
style={{
background: "#f9fafb",
borderRadius: "16px",
padding: "24px 16px",
display: "flex",
flexDirection: "column",
gap: "0px",
alignItems: "center",
width: "446px",
}}
>
{/* From USDC */}
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
alignItems: "flex-start",
}}
>
<div
style={{
color: "#111827",
fontSize: "24px",
fontWeight: 700,
lineHeight: "130%",
letterSpacing: "-0.005em",
fontFamily: "var(--font-inter)",
}}
>
{amount || "0"} USDC
</div>
<div
style={{
color: "#4b5563",
fontSize: "16px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
${usdValue}
</div>
</div>
<Image
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
alt="USDC"
width={36}
height={36}
/>
</div>
{/* Exchange Icon */}
<div
style={{
background: "#ffffff",
borderRadius: "999px",
width: "40px",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow:
"0px 1px 2px -1px rgba(0, 0, 0, 0.1), 0px 1px 3px 0px rgba(0, 0, 0, 0.1)",
}}
>
<Image
src="/components/common/group-9280.svg"
alt="Exchange"
width={8}
height={16}
style={{ objectFit: "contain" }}
/>
</div>
{/* To ALP */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0px",
alignItems: "flex-start",
width: "100%",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
}}
>
<div
style={{
color: "#111827",
fontSize: "24px",
fontWeight: 700,
lineHeight: "130%",
letterSpacing: "-0.005em",
fontFamily: "var(--font-inter)",
}}
>
{alpAmount} ALP
</div>
<div
style={{
width: "36px",
height: "36px",
position: "relative",
}}
>
<Image
src="/components/common/vector0.svg"
alt=""
width={36}
height={36}
style={{ position: "absolute" }}
/>
<Image
src="/components/common/vector1.svg"
alt=""
width={36}
height={36}
style={{ position: "absolute" }}
/>
<Image
src="/components/common/vector2.svg"
alt=""
width={36}
height={36}
style={{ position: "absolute" }}
/>
<Image
src="/components/common/component-10.svg"
alt=""
width={36}
height={36}
style={{ position: "absolute" }}
/>
</div>
</div>
<div
style={{
color: "#4b5563",
fontSize: "16px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
${usdValue}
</div>
</div>
</div>
{/* Show More/Less Button */}
<button
onClick={() => setShowDetails(!showDetails)}
style={{
background: "#ffffff",
borderRadius: "999px",
padding: "0px 16px",
border: "none",
cursor: "pointer",
display: "flex",
flexDirection: "row",
gap: "0px",
alignItems: "center",
justifyContent: "center",
width: "120px",
height: "32px",
boxShadow:
"0px 1px 2px -1px rgba(0, 0, 0, 0.1), 0px 1px 3px 0px rgba(0, 0, 0, 0.1)",
}}
>
<div
style={{
color: "#4b5563",
fontSize: "14px",
fontWeight: 500,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{showDetails ? "Show less" : "Show more"}
</div>
<Image
src="/components/common/icon1.svg"
alt=""
width={12}
height={12}
style={{
transform: showDetails ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease-in-out",
}}
/>
</button>
{/* Transaction Details */}
{showDetails && (
<div
style={{
background: "#f9fafb",
borderRadius: "16px",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "8px",
width: "446px",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Fee(0.5%)
</div>
<div
style={{
color: "#ff6900",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
-${fee}
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Network cost
</div>
<div
style={{
color: "#ff6900",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
-$0.09
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Rate
</div>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
1USDC=0.98ALP ($1.02)
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Spread
</div>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
0
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Price Impact
</div>
<div
style={{
color: "#10b981",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
+$0.60 (+0.065%)
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Max slippage
</div>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
0.5%
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
}}
>
<div
style={{
color: "#9ca1af",
fontSize: "14px",
fontWeight: 400,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
Min Receive
</div>
<div
style={{
color: "#111827",
fontSize: "14px",
fontWeight: 700,
lineHeight: "150%",
fontFamily: "var(--font-inter)",
}}
>
{minReceive} ALP
</div>
</div>
</div>
)}
</div>
{/* Confirm Button */}
<Button
color="default"
variant="solid"
className={buttonStyles({ intent: "theme" })}
onPress={() => {
onClose();
}}
>
Confirm
</Button>
</div>
</ModalContent>
</Modal>
);
}

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

View File

@@ -0,0 +1,120 @@
// 常用图标组件 - 转换为React组件以减少HTTP请求
export interface IconProps {
width?: number;
height?: number;
className?: string;
color?: string;
}
// 导航图标
export const IconLending = ({ width = 20, height = 20, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 2L2 6v6c0 4.42 3.03 8.13 7 9 3.97-.87 7-4.58 7-9V6l-8-4z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconAlp = ({ width = 20, height = 20, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 17h14M5 17V9l5-6 5 6v8M8 17v-4h4v4" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconSwap = ({ width = 20, height = 20, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 9h12M12 5l4 4-4 4M16 11H4M8 15l-4-4 4-4" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconTransparency = ({ width = 20, height = 20, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18a8 8 0 100-16 8 8 0 000 16zM10 6v6M10 14h.01" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconEcosystem = ({ width = 20, height = 20, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 10h4M13 10h4M10 3v4M10 13v4M6.34 6.34l2.83 2.83M10.83 10.83l2.83 2.83M6.34 13.66l2.83-2.83M10.83 9.17l2.83-2.83" stroke={color} strokeWidth="1.5" strokeLinecap="round"/>
</svg>
);
export const IconPoints = ({ width = 20, height = 20, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 2l2.5 6.5L19 10l-6.5 2.5L10 19l-2.5-6.5L1 10l6.5-2.5L10 2z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
// 操作图标
export const IconCopy = ({ width = 16, height = 16, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 5V3a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2h-2M3 11a2 2 0 002 2h6a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v6z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconWallet = ({ width = 20, height = 20, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 5a2 2 0 012-2h10a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM3 8h14M13 11h2" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconNotification = ({ width = 20, height = 20, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 7a5 5 0 10-10 0c0 5-2 7-2 7h14s-2-2-2-7zM11.73 16a2 2 0 01-3.46 0" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconRefresh = ({ width = 16, height = 16, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 8A6 6 0 104 4.5M4 2v2.5h2.5" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconArrowRight = ({ width = 16, height = 16, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 8h10M9 4l4 4-4 4" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
// UI图标
export const IconChevronLeft = ({ width = 10, height = 10, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 2L3 5l3 3" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconChevronRight = ({ width = 10, height = 10, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 2l3 3-3 3" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconChart = ({ width = 16, height = 16, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 14V6l4-4 4 4 4-4v12" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconClose = ({ width = 20, height = 20, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 5L5 15M5 5l10 10" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
// 用户图标
export const IconWhale = ({ width = 24, height = 24, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 12c0-3.5 2-6 5-7 3-1 6-.5 8 1.5 2 2 3 5 3 8s-2 5-5 5-6-1-8-3.5c-1-1.5-3-2-3-4z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconTrader = ({ width = 24, height = 24, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 21V9l9-6 9 6v12M9 21v-6h6v6" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export const IconUser = ({ width = 24, height = 24, className = "", color = "currentColor" }: IconProps) => (
<svg width={width} height={height} className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);

View File

@@ -0,0 +1,53 @@
"use client";
import Image from "next/image";
import Link from "next/link";
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
}
export default function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav className="flex items-center gap-[3px] h-5">
{items.map((item, index) => {
const isLast = index === items.length - 1;
const content = (
<span
className={`text-sm font-medium leading-[150%] ${
isLast
? "text-text-primary dark:text-white font-bold"
: "text-text-tertiary dark:text-gray-400 hover:text-text-secondary dark:hover:text-gray-300 transition-colors cursor-pointer"
}`}
>
{item.label}
</span>
);
return (
<div key={index} className="flex items-center gap-[3px]">
{!isLast && item.href ? (
<Link href={item.href}>{content}</Link>
) : (
content
)}
{!isLast && (
<Image
src="/icons/ui/icon-chevron-right.svg"
alt=""
width={14}
height={14}
className="flex-shrink-0 dark:invert"
/>
)}
</div>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { useApp } from "@/contexts/AppContext";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Button } from "@heroui/react";
export default function LanguageSwitch() {
const { language, setLanguage } = useApp();
const languages = [
{ key: "zh", label: "中文" },
{ key: "en", label: "English" },
];
const handleSelectionChange = (key: React.Key) => {
setLanguage(key as "zh" | "en");
};
return (
<Dropdown shouldBlockScroll={false}>
<DropdownTrigger>
<Button
variant="bordered"
className="bg-bg-surface dark:bg-gray-800 border-border-normal dark:border-gray-700 min-w-10 h-10 px-3 rounded-lg"
>
<span className="text-sm font-medium text-text-primary dark:text-white">
{language === "zh" ? "中" : "EN"}
</span>
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Language selection"
selectedKeys={new Set([language])}
selectionMode="single"
onSelectionChange={(keys) => {
const key = Array.from(keys)[0];
if (key) handleSelectionChange(key);
}}
>
{languages.map((lang) => (
<DropdownItem key={lang.key}>{lang.label}</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
);
}

View File

@@ -0,0 +1,19 @@
interface PageTitleProps {
title: string;
subtitle?: string;
}
export default function PageTitle({ title, subtitle }: PageTitleProps) {
return (
<div className="mb-8">
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white">
{title}
</h1>
{subtitle && (
<p className="text-body-large text-text-secondary dark:text-gray-400 mt-2">
{subtitle}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { ReactNode } from "react";
interface SectionHeaderProps {
title: string;
children?: ReactNode;
}
export default function SectionHeader({ title, children }: SectionHeaderProps) {
return (
<div className="flex flex-row items-center justify-between">
<h2 className="text-text-primary dark:text-white text-heading-h3 font-bold">
{title}
</h2>
{children && <div>{children}</div>}
</div>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
import { ScrollShadow } from "@heroui/react";
import {
Menu, X,
TrendingUp, BarChart3, ArrowLeftRight, Landmark,
ShieldCheck, Network, Sparkles, HelpCircle,
type LucideIcon,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useApp } from "@/contexts/AppContext";
import WalletButton from "@/components/wallet/WalletButton";
import LanguageSwitch from "./LanguageSwitch";
import { fetchStats } from "@/lib/api/fundmarket";
interface NavItem {
Icon: LucideIcon;
label: string;
key: string;
path: string;
}
export default function Sidebar() {
const { t } = useApp();
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const { data: stats = [] } = useQuery({
queryKey: ["fundmarket-stats"],
queryFn: fetchStats,
staleTime: 5 * 60 * 1000,
});
const tvlValue = stats[0]?.value ?? "$0";
const navigationItems: NavItem[] = [
{ Icon: TrendingUp, label: t("nav.fundMarket"), key: "FundMarket", path: "/market" },
{ Icon: BarChart3, label: t("nav.alp"), key: "ALP", path: "/alp" },
{ Icon: ArrowLeftRight, label: t("nav.swap"), key: "Swap", path: "/swap" },
{ Icon: Landmark, label: t("nav.lending"), key: "Lending", path: "/lending" },
{ Icon: ShieldCheck, label: t("nav.transparency"), key: "Transparency", path: "/transparency" },
{ Icon: Network, label: t("nav.ecosystem"), key: "Ecosystem", path: "/ecosystem" },
{ Icon: Sparkles, label: t("nav.points"), key: "Points", path: "/points" },
];
const isActive = (path: string) =>
pathname === path || pathname.startsWith(path + "/");
// 桌面端导航项
const renderDesktopNavItems = (onClick: (path: string) => void) =>
navigationItems.map(({ Icon, label, key, path }) => {
const active = isActive(path);
return (
<div key={key} className="relative group">
{active && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-text-primary dark:bg-white rounded-r-full" />
)}
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-text-primary/0 dark:bg-white/0 group-hover:bg-text-primary/20 dark:group-hover:bg-white/20 rounded-r-full transition-all duration-300 scale-y-0 group-hover:scale-y-100 origin-center" />
<button
onClick={() => onClick(path)}
className={`
w-full h-11 px-4 rounded-xl gap-3 flex items-center relative
transition-all duration-200
${active
? "bg-fill-secondary-click dark:bg-gray-700 font-bold"
: "hover:bg-gray-100 dark:hover:bg-gray-700"
}
`}
>
<Icon
size={18}
className={`flex-shrink-0 transition-all duration-200 ${
active
? "text-text-primary dark:text-white"
: "text-text-secondary dark:text-gray-400 group-hover:text-text-primary dark:group-hover:text-white"
}`}
/>
<span className={`
text-sm leading-[150%] transition-all duration-200
${active
? "text-text-primary dark:text-white"
: "text-text-secondary dark:text-gray-400 group-hover:translate-x-1"
}
`}>
{label}
</span>
</button>
</div>
);
});
// 移动端抽屉导航项pill 样式)
const renderMobileNavItems = () =>
navigationItems.map(({ Icon, label, key, path }) => {
const active = isActive(path);
return (
<button
key={key}
onClick={() => { router.push(path); setMobileOpen(false); }}
className={`
w-full flex items-center gap-4 px-4 py-3.5 rounded-2xl
transition-all duration-200 text-left
${active
? "bg-gray-900 dark:bg-white"
: "hover:bg-gray-100 dark:hover:bg-gray-700/60"
}
`}
>
<Icon
size={18}
className={`flex-shrink-0 ${
active ? "text-white dark:text-gray-900" : "text-gray-400 dark:text-gray-400"
}`}
/>
<span className={`text-[15px] font-semibold leading-[150%] ${
active ? "text-white dark:text-gray-900" : "text-gray-700 dark:text-gray-200"
}`}>
{label}
</span>
{active && (
<div className="ml-auto w-1.5 h-1.5 rounded-full bg-white dark:bg-gray-900" />
)}
</button>
);
});
return (
<>
{/* ── MOBILE NAV BAR ── */}
<nav className="md:hidden fixed top-0 left-0 right-0 z-50 h-14 bg-bg-surface dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 flex items-center justify-between px-4">
<div
className="h-8 w-28 relative cursor-pointer"
onClick={() => { router.push("/market"); setMobileOpen(false); }}
>
<Image src="/logos/logo.svg" alt="ASSETX Logo" fill className="object-contain dark:invert" />
</div>
<div className="flex items-center gap-2">
<WalletButton compact />
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="w-10 h-10 flex items-center justify-center rounded-lg border border-border-normal dark:border-gray-700 bg-bg-surface dark:bg-gray-800"
aria-label="Toggle menu"
>
{mobileOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
</nav>
{/* ── MOBILE DRAWER BACKDROP ── */}
<div
className={`md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm transition-opacity duration-300 ${mobileOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
onClick={() => setMobileOpen(false)}
/>
{/* ── MOBILE DRAWER (右侧滑出) ── */}
<aside
className={`md:hidden fixed top-0 right-0 bottom-0 z-50 w-[85vw] max-w-[320px] bg-white dark:bg-gray-900 flex flex-col shadow-[-12px_0_40px_rgba(0,0,0,0.15)] transition-transform duration-300 ease-out ${mobileOpen ? "translate-x-0" : "translate-x-full"}`}
>
{/* 顶部Logo + 关闭按钮 */}
<div className="flex-shrink-0 flex items-center justify-between px-5 pt-12 pb-6 landscape:pt-4 landscape:pb-3">
<div className="h-7 w-24 relative">
<Image src="/logos/logo.svg" alt="ASSETX Logo" fill className="object-contain dark:invert" />
</div>
<button
onClick={() => setMobileOpen(false)}
className="w-9 h-9 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
>
<X size={18} />
</button>
</div>
{/* 导航项 */}
<ScrollShadow className="flex-1 px-3 overflow-y-auto" hideScrollBar>
<nav className="flex flex-col gap-1">
{renderMobileNavItems()}
</nav>
</ScrollShadow>
{/* 底部TVL + Language + FAQ */}
<div className="flex-shrink-0 px-5 py-4 landscape:py-2 space-y-3 landscape:space-y-2">
<div className="bg-gray-50 dark:bg-gray-800 rounded-2xl px-4 py-2 landscape:py-1.5">
<p className="text-gray-400 dark:text-gray-500 text-[11px] font-semibold uppercase tracking-wider mb-0.5">
{t("nav.globalTVL")}
</p>
<p className="text-gray-900 dark:text-white text-lg font-extrabold font-inter landscape:text-base">
{tvlValue}
</p>
</div>
<div className="flex items-center justify-between">
<button
onClick={() => { router.push("/faq"); setMobileOpen(false); }}
className="flex items-center gap-2 h-10 px-4 rounded-xl bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<HelpCircle size={16} />
{t("nav.faqs")}
</button>
<LanguageSwitch />
</div>
</div>
</aside>
{/* ── DESKTOP SIDEBAR ── */}
<aside className="hidden md:flex fixed left-0 top-0 bg-bg-surface dark:bg-gray-800 border-r border-border-normal dark:border-gray-700 flex-col h-screen w-[240px] overflow-hidden">
{/* Logo */}
<div className="flex-shrink-0 px-6 py-8">
<div className="h-10 relative cursor-pointer" onClick={() => router.push("/market")}>
<Image src="/logos/logo.svg" alt="ASSETX Logo" fill className="object-contain dark:invert" />
</div>
</div>
{/* Navigation */}
<ScrollShadow className="flex-1 px-4" hideScrollBar>
<nav className="flex flex-col gap-1 py-1">
{renderDesktopNavItems((path) => router.push(path))}
</nav>
</ScrollShadow>
{/* Bottom Section */}
<div className="flex-shrink-0 px-4 py-6 border-t border-border-gray dark:border-gray-700">
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl p-4 mb-4">
<p className="text-text-tertiary dark:text-gray-400 text-[10px] font-medium leading-[150%] tracking-[0.01em]">
{t("nav.globalTVL")}
</p>
<p className="text-text-primary dark:text-white text-lg font-extrabold leading-[150%] font-inter mt-1">
{tvlValue}
</p>
</div>
<button
onClick={() => router.push("/faq")}
className="w-full rounded-xl flex items-center gap-3 h-11 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200"
>
<HelpCircle size={18} className="text-text-primary dark:text-white flex-shrink-0" />
<span className="text-text-primary dark:text-white text-sm font-bold leading-[150%]">
{t("nav.faqs")}
</span>
</button>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import Breadcrumb from "./Breadcrumb";
import LanguageSwitch from "./LanguageSwitch";
import WalletButton from "@/components/wallet/WalletButton";
interface BreadcrumbItem {
label: string;
href?: string;
}
interface TopBarProps {
breadcrumbItems?: BreadcrumbItem[];
}
export default function TopBar({ breadcrumbItems }: TopBarProps) {
return (
<div className="hidden md:flex items-center justify-between w-full">
{/* Left: Breadcrumb */}
<div className="flex items-center gap-2">
{breadcrumbItems && <Breadcrumb items={breadcrumbItems} />}
</div>
{/* Right: Actions */}
<div className="flex items-center gap-4 pr-4">
{/* Language Switch */}
<LanguageSwitch />
{/* Wallet Button */}
<WalletButton />
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useRouter } from "next/navigation";
import { useApp } from "@/contexts/AppContext";
import { useAccount } from "wagmi";
import { useCollateralBalance, useCollateralValue } from "@/hooks/useCollateral";
import { fetchProducts } from "@/lib/api/fundmarket";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
interface BorrowMarketItemData {
tokenType: string;
icon: string;
iconBg: string;
name: string;
category: string;
contractAddress: string;
}
// 渐变色映射(用于不同的 YT token
const GRADIENT_COLORS: Record<string, string> = {
'YT-A': 'linear-gradient(135deg, #FF8904 0%, #F54900 100%)',
'YT-B': 'linear-gradient(135deg, #00BBA7 0%, #007A55 100%)',
'YT-C': 'linear-gradient(135deg, #667EEA 0%, #764BA2 100%)',
};
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #6B7280 0%, #374151 100%)';
// 单个代币行组件
function BorrowMarketItem({ tokenData }: { tokenData: BorrowMarketItemData }) {
const { t } = useApp();
const router = useRouter();
const { isConnected } = useAccount();
const [mounted, setMounted] = useState(false);
// 查询该代币的抵押品余额
const ytToken = useTokenBySymbol(tokenData.tokenType);
const { formattedBalance, isLoading: isBalanceLoading } = useCollateralBalance(ytToken);
// 查询抵押品价值(用 valueRaw 避免 toFixed 截断误差)
const { valueRaw: collateralValueRaw } = useCollateralValue(ytToken);
useEffect(() => {
setMounted(true);
}, []);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-6 flex items-center gap-3">
{/* Token Info */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center overflow-hidden shadow-md"
style={{ background: tokenData.iconBg }}>
<Image src={tokenData.icon || "/assets/tokens/default.svg"} alt={tokenData.name} width={32} height={32} />
</div>
<div className="flex flex-col min-w-0">
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%] truncate">
{tokenData.name}
</span>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] truncate">
{tokenData.category}
</span>
</div>
</div>
{/* Your Balance */}
<div className="flex flex-col items-center flex-1">
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("lending.yourBalance")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%] whitespace-nowrap">
{!mounted || isBalanceLoading ? '...' : `$${collateralValueRaw.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`}
</span>
<span className="text-[10px] text-text-tertiary dark:text-gray-400 whitespace-nowrap">
{!mounted || isBalanceLoading ? '' : `${parseFloat(formattedBalance).toLocaleString()} ${tokenData.name}`}
</span>
</div>
{/* View Details Button */}
<Button
className={`shrink-0 rounded-xl h-10 px-4 text-body-small font-bold transition-opacity ${
!isConnected || !tokenData.contractAddress
? '!bg-[#E5E7EB] !text-[#9CA1AF] cursor-not-allowed'
: 'bg-foreground text-background hover:opacity-80'
}`}
isDisabled={!isConnected || !tokenData.contractAddress}
onPress={() => router.push(`/lending/repay?token=${tokenData.tokenType}`)}
>
{t("lending.viewDetails") || "View Details"}
</Button>
</div>
);
}
// 主组件
export default function BorrowMarket({ market }: { market: 'USDC' | 'USDT' }) {
const { t } = useApp();
const [tokens, setTokens] = useState<BorrowMarketItemData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadTokens() {
setLoading(true);
try {
const products = await fetchProducts();
const tokenList: BorrowMarketItemData[] = products.map((product) => ({
tokenType: product.tokenSymbol,
icon: product.iconUrl,
iconBg: GRADIENT_COLORS[product.name] || 'linear-gradient(135deg, #667EEA 0%, #764BA2 100%)',
name: product.name,
category: product.category || `Yield Token ${product.name.split('-')[1]}`,
contractAddress: product.contractAddress || '',
}));
setTokens(tokenList);
} catch (error) {
console.error('Failed to fetch tokens:', error);
} finally {
setLoading(false);
}
}
loadTokens();
}, []);
if (loading) {
return (
<div className="flex flex-col gap-3">
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{market} {t("lending.borrowMarket")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6 h-[120px] animate-pulse">
<div className="flex items-center gap-8">
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/4" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="flex flex-col gap-3">
{/* Title */}
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{market} {t("lending.borrowMarket")}
</h2>
{/* Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tokens.map((tokenData) => (
<BorrowMarketItem
key={tokenData.tokenType}
tokenData={tokenData}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import { useApp } from "@/contexts/AppContext";
import { useReadContract, useReadContracts } from "wagmi";
import { useAccount } from "wagmi";
import { formatUnits } from "viem";
import { abis, getContractAddress } from "@/lib/contracts";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
import { useTokenList } from "@/hooks/useTokenList";
interface LendingHeaderProps {
market: 'USDC' | 'USDT';
}
export default function LendingHeader({ market }: LendingHeaderProps) {
const { t } = useApp();
const { chainId } = useAccount();
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined;
const usdcToken = useTokenBySymbol('USDC');
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18;
// 链上总供应量
const { data: totalSupply } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getTotalSupply',
query: { enabled: !!lendingProxyAddress },
});
// 链上利用率1e18 精度)
const { data: utilization } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getUtilization',
query: { enabled: !!lendingProxyAddress },
});
// 全市场抵押品总量USD
// 用 ERC20.balanceOf(lendingProxy) 获取合约实际持有的 YT token 数量
// (用户存入的抵押品都在合约里,这是最准确的总量)
const { yieldTokens } = useTokenList();
const ytAddresses = yieldTokens.map(t => t.contractAddress).filter(Boolean) as `0x${string}`[];
const erc20BalanceAbi = [{
name: 'balanceOf',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'account', type: 'address' }],
outputs: [{ name: '', type: 'uint256' }],
}] as const;
// 批量读每个 YT token 合约里 lendingProxy 的余额
const { data: balancesData } = useReadContracts({
contracts: ytAddresses.map(addr => ({
address: addr,
abi: erc20BalanceAbi,
functionName: 'balanceOf' as const,
args: [lendingProxyAddress as `0x${string}`],
chainId,
})),
query: { enabled: !!lendingProxyAddress && ytAddresses.length > 0 },
});
// 批量读每个 YT token 的价格30 位精度)
const { data: pricesData } = useReadContracts({
contracts: ytAddresses.map(addr => ({
address: addr as `0x${string}`,
abi: abis.YTToken as any,
functionName: 'ytPrice' as const,
args: [],
chainId,
})),
query: { enabled: ytAddresses.length > 0 },
});
// 计算总抵押品 USD 价值:Σ(balance[i] / 1e18 * price[i] / 1e30)
const totalCollateralUSD = (() => {
if (!balancesData || !pricesData) return null;
let sum = 0;
ytAddresses.forEach((_, i) => {
const bal = balancesData[i];
const pri = pricesData[i];
if (bal?.status !== 'success' || pri?.status !== 'success') return;
const balance = Number(formatUnits(bal.result as bigint, 18));
const price = Number(formatUnits(pri.result as bigint, 30));
sum += balance * price;
});
return sum;
})();
const displayTotalCollateral = totalCollateralUSD !== null
? `$${totalCollateralUSD.toLocaleString('en-US', { maximumFractionDigits: 2 })}`
: '--';
// 格式化总供应量
const displaySupply = totalSupply != null
? `$${parseFloat(formatUnits(totalSupply as bigint, usdcDecimals)).toLocaleString('en-US', { maximumFractionDigits: 2 })}`
: '--';
// 格式化利用率
const displayUtilization = utilization != null
? `${(Number(formatUnits(utilization as bigint, 18)) * 100).toFixed(1)}%`
: '--';
const stats = [
{
label: t("lending.totalUsdcSupply"),
value: displaySupply,
valueColor: "text-text-primary dark:text-white",
},
{
label: t("lending.utilization"),
value: displayUtilization,
valueColor: "text-[#10b981] dark:text-green-400",
},
{
label: t("lending.totalCollateral"),
value: displayTotalCollateral,
valueColor: "text-text-primary dark:text-white",
},
];
return (
<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-4 md:gap-6">
{/* Title Section */}
<div className="flex flex-col gap-0">
<h1 className="text-2xl md:text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">
{market} {t("lending.title")}
</h1>
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("lending.subtitle")}
</p>
</div>
{/* Stats Cards移动端2列桌面端3列 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 w-full">
{stats.map((stat, index) => (
<div
key={index}
className="bg-bg-subtle dark:bg-gray-700 rounded-2xl border border-border-gray dark:border-gray-600 p-3 md:p-4 flex flex-col gap-2"
>
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{stat.label}
</span>
<span className={`text-xl md:text-heading-h2 font-bold leading-[130%] tracking-[-0.01em] ${stat.valueColor}`}>
{stat.value}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount } from "wagmi";
import { useSuppliedBalance } from "@/hooks/useLendingSupply";
import { useCollateralValue } from "@/hooks/useCollateral";
import { useTokenList } from "@/hooks/useTokenList";
export default function LendingPlaceholder() {
const { t } = useApp();
const router = useRouter();
const { isConnected } = useAccount();
const { bySymbol } = useTokenList();
const { formattedBalance: suppliedBalance } = useSuppliedBalance();
const { valueRaw: valueA } = useCollateralValue(bySymbol['YT-A']);
const { valueRaw: valueB } = useCollateralValue(bySymbol['YT-B']);
const { valueRaw: valueC } = useCollateralValue(bySymbol['YT-C']);
const totalPortfolio = isConnected
? parseFloat(suppliedBalance) + valueA + valueB + valueC
: 0;
const portfolioDisplay = `$${totalPortfolio.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const collateralTotal = valueA + valueB + valueC;
return (
<div className="relative rounded-2xl h-[180px] overflow-hidden">
{/* Dark Background */}
<div className="absolute inset-0 bg-[#0f172b]" />
{/* Green Glow Effect */}
<div
className="absolute w-32 h-32 rounded-full right-0 top-[-64px]"
style={{
background: "rgba(0, 188, 125, 0.2)",
filter: "blur(40px)",
}}
/>
{/* Content - Centered */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col gap-4 w-[90%]">
{/* Your Portfolio Section */}
<div className="flex flex-col gap-2">
{/* Label Row */}
<div className="flex items-center justify-between h-[21px]">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("lending.yourPortfolio")}
</span>
{collateralTotal > 0 && (
<span className="text-[10px] font-bold text-[#10b981] dark:text-green-400 leading-[150%] tracking-[0.01em]">
+${collateralTotal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {t("lending.collateral")}
</span>
)}
</div>
{/* Amount */}
<div className="flex items-center gap-2">
<span className="text-heading-h2 font-bold text-white leading-[130%] tracking-[-0.01em]">
{portfolioDisplay}
</span>
</div>
</div>
{/* Supply USDC Button */}
<Button
color="default"
variant="solid"
className="h-11 rounded-xl px-6 text-body-small font-bold bg-[#282E3F] dark:bg-white dark:text-[#282E3F] text-background"
onPress={() => router.push("/lending/supply")}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
>
{t("lending.supplyUsdc")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import Image from "next/image";
import { useState, useEffect } from "react";
import { useApp } from "@/contexts/AppContext";
import { useReadContract } from "wagmi";
import { useAccount } from "wagmi";
import { formatUnits } from "viem";
import { abis, getContractAddress } from "@/lib/contracts";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
import { useQuery } from "@tanstack/react-query";
import { fetchLendingAPYHistory } from "@/lib/api/lending";
import { useLTV } from "@/hooks/useCollateral";
export default function LendingStats() {
const { t } = useApp();
const { chainId } = useAccount();
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined;
const usdcToken = useTokenBySymbol('USDC');
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18;
// 链上总借款量
const { data: totalBorrow } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getTotalBorrow',
query: { enabled: !!lendingProxyAddress },
});
// 链上利用率1e18 精度)
const { data: utilization } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getUtilization',
query: { enabled: !!lendingProxyAddress },
});
// 链上供应利率(年化 APR1e18 精度)
const { data: supplyRate } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getSupplyRate',
query: { enabled: !!lendingProxyAddress },
});
// 格式化总借款
const displayBorrowed = totalBorrow != null
? `$${parseFloat(formatUnits(totalBorrow as bigint, usdcDecimals)).toLocaleString('en-US', { maximumFractionDigits: 2 })}`
: '--';
// 格式化利用率
const utilizationPct = utilization != null
? (Number(formatUnits(utilization as bigint, 18)) * 100).toFixed(1)
: null;
// 计算供应 APYgetSupplyRate 返回年化 APR1e18 精度)
const SECONDS_PER_YEAR = 365 * 24 * 60 * 60;
const annualApr = supplyRate != null ? Number(formatUnits(supplyRate as bigint, 18)) : 0;
const perSecondRate = annualApr / SECONDS_PER_YEAR;
const supplyApy = perSecondRate > 0
? (Math.pow(1 + perSecondRate, SECONDS_PER_YEAR) - 1) * 100
: null;
const displayApy = supplyApy != null ? `${supplyApy.toFixed(2)}%` : '--';
// APY 历史数据 → 迷你柱状图
const { data: apyHistory } = useQuery({
queryKey: ['lending-apy-history-mini', chainId],
queryFn: () => fetchLendingAPYHistory('1W', chainId),
staleTime: 5 * 60 * 1000,
});
// 从历史数据中均匀采样 6 个点,归一化为柱高百分比
const chartBars = (() => {
const BAR_COLORS = ["#f2fcf7", "#e1f8ec", "#cef3e0", "#b8ecd2", "#00ad76", "#10b981"];
const points = apyHistory?.history ?? [];
if (points.length === 0) {
return BAR_COLORS.map((color, i) => ({ height: [40, 55, 45, 65, 80, 95][i], color, apy: null as number | null, time: null as string | null }));
}
const step = Math.max(1, Math.floor(points.length / 6));
const sampled = Array.from({ length: 6 }, (_, i) => {
const idx = Math.min(i * step, points.length - 1);
return points[idx];
});
const apyValues = sampled.map(p => p.supply_apy);
const min = Math.min(...apyValues);
const max = Math.max(...apyValues);
const range = max - min || 1;
return sampled.map((p, i) => ({
height: Math.round(20 + ((p.supply_apy - min) / range) * 75),
color: BAR_COLORS[i],
apy: p.supply_apy,
time: p.time,
}));
})();
const [hoveredBar, setHoveredBar] = useState<number | null>(null);
// 用户当前 LTV
const { ltv, ltvRaw } = useLTV();
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8">
{/* 移动端2列网格 + 柱状图 */}
<div className="md:hidden flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
{/* USDC Borrowed */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">{t("lending.usdcBorrowed")}</span>
<span className="text-xl font-bold text-text-primary dark:text-white">{displayBorrowed}</span>
<div className="flex items-center gap-1">
<Image src="/components/buy/icon2.svg" alt="" width={16} height={16} />
<span className="text-[11px] font-medium text-[#10b981]">
{utilizationPct != null ? `${utilizationPct}% ${t("lending.utilization")}` : t("lending.vsLastMonth")}
</span>
</div>
</div>
{/* Avg. APY */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">{t("lending.avgApy")}</span>
<span className="text-xl font-bold text-[#10b981]">{displayApy}</span>
<span className="text-[11px] text-text-tertiary dark:text-gray-400">{t("lending.stableYield")}</span>
</div>
{/* LTV */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400">{t("lending.ltv")}</span>
<span className={`text-xl font-bold ${
!mounted ? 'text-text-primary dark:text-white' :
ltvRaw === 0 ? 'text-text-tertiary dark:text-gray-400' :
ltvRaw < 50 ? 'text-[#10b981]' :
ltvRaw < 70 ? 'text-[#ff6900]' : 'text-[#ef4444]'
}`}>{!mounted ? '--' : `${ltv}%`}</span>
<span className="text-[11px] text-text-tertiary dark:text-gray-400">Loan-to-Value</span>
</div>
</div>
{/* 移动端柱状图 */}
<div className="flex items-end gap-1 h-16 relative">
{chartBars.map((bar, index) => (
<div
key={index}
className="flex-1 flex flex-col items-center justify-end gap-0.5"
style={{ height: '100%' }}
>
{bar.apy !== null && (
<span className="text-[9px] font-medium text-[#10b981] leading-none whitespace-nowrap">
{bar.apy.toFixed(2)}%
</span>
)}
<div
className="w-full rounded-t-md"
style={{ backgroundColor: bar.color, height: `${bar.height}%` }}
/>
</div>
))}
</div>
</div>
{/* 桌面端:横排 + 图表 */}
<div className="hidden md:flex items-center gap-6 h-[116px]">
<div className="flex items-center gap-12 flex-1">
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{t("lending.usdcBorrowed")}</span>
<span className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">{displayBorrowed}</span>
<div className="flex items-center gap-1">
<Image src="/components/buy/icon2.svg" alt="" width={20} height={20} />
<span className="text-[12px] font-medium text-[#10b981] dark:text-green-400 leading-[16px]">
{utilizationPct != null ? `${utilizationPct}% ${t("lending.utilization")}` : t("lending.vsLastMonth")}
</span>
</div>
</div>
<div className="w-px h-12 bg-border-gray dark:bg-gray-600" />
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{t("lending.avgApy")}</span>
<span className="text-heading-h2 font-bold text-[#10b981] dark:text-green-400 leading-[130%] tracking-[-0.01em]">{displayApy}</span>
<span className="text-[12px] font-regular text-text-tertiary dark:text-gray-400 leading-[16px]">{t("lending.stableYield")}</span>
</div>
<div className="w-px h-12 bg-border-gray dark:bg-gray-600" />
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{t("lending.ltv")}</span>
<span className={`text-heading-h2 font-bold leading-[130%] tracking-[-0.01em] ${
!mounted ? 'text-text-primary dark:text-white' :
ltvRaw === 0 ? 'text-text-tertiary dark:text-gray-400' :
ltvRaw < 50 ? 'text-[#10b981] dark:text-green-400' :
ltvRaw < 70 ? 'text-[#ff6900] dark:text-orange-400' :
'text-[#ef4444] dark:text-red-400'
}`}>{!mounted ? '--' : `${ltv}%`}</span>
<span className="text-[12px] font-regular text-text-tertiary dark:text-gray-400 leading-[16px]">Loan-to-Value</span>
</div>
</div>
{/* Mini Chart */}
<div className="flex items-end gap-1 w-48 h-16 relative">
{chartBars.map((bar, index) => (
<div
key={index}
className="flex-1 relative group"
style={{ height: '100%', display: 'flex', alignItems: 'flex-end' }}
onMouseEnter={() => setHoveredBar(index)}
onMouseLeave={() => setHoveredBar(null)}
>
{hoveredBar === index && bar.apy !== null && (
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 z-10 whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none flex flex-col items-center gap-0.5">
<span className="text-[11px] font-bold text-[#10b981]">{bar.apy.toFixed(4)}%</span>
{bar.time && <span className="text-[10px] text-gray-400">{new Date(bar.time).toLocaleDateString('en-GB', { month: 'short', day: 'numeric' })}</span>}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" />
</div>
)}
<div className="w-full rounded-t-md transition-all duration-150" style={{ backgroundColor: hoveredBar === index ? '#10b981' : bar.color, height: `${bar.height}%` }} />
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,338 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount } from "wagmi";
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
import { useLendingSupply, useSuppliedBalance } from "@/hooks/useLendingSupply";
import { useLendingWithdraw } from "@/hooks/useLendingWithdraw";
import { useBorrowBalance, useMaxBorrowable } from "@/hooks/useCollateral";
import { notifyLendingBorrow, notifyLendingRepay } from "@/lib/api/lending";
import { useAppKit } from "@reown/appkit/react";
import { toast } from "sonner";
import { getTxUrl, getContractAddress, abis } from "@/lib/contracts";
import { useTokenDecimalsFromAddress } from "@/hooks/useTokenDecimals";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
interface RepayBorrowDebtProps {
refreshTrigger?: number;
}
export default function RepayBorrowDebt({ refreshTrigger }: RepayBorrowDebtProps) {
const { t } = useApp();
const { isConnected } = useAccount();
const { open } = useAppKit();
const [mounted, setMounted] = useState(false);
const [activeTab, setActiveTab] = useState<'borrow' | 'repay'>('borrow');
const [amount, setAmount] = useState('');
useEffect(() => { setMounted(true); }, []);
// On-chain data
const { formattedBalance: suppliedBalance, refetch: refetchSupply } = useSuppliedBalance();
const { formattedBalance: borrowedBalance, refetch: refetchBorrow } = useBorrowBalance();
const { formattedAvailable, available, refetch: refetchMaxBorrowable } = useMaxBorrowable();
// Refresh when collateral changes (triggered by RepaySupplyCollateral)
useEffect(() => {
if (refreshTrigger === undefined || refreshTrigger === 0) return;
refetchBorrow();
refetchMaxBorrowable();
}, [refreshTrigger]);
// Borrow = withdraw (takes USDC from protocol, increasing debt)
const {
status: borrowStatus,
error: borrowError,
isLoading: isBorrowing,
withdrawHash: borrowHash,
executeWithdraw: executeBorrow,
reset: resetBorrow,
} = useLendingWithdraw();
// Repay = supply USDC (reduces debt)
const {
status: repayStatus,
error: repayError,
isLoading: isRepaying,
approveHash: repayApproveHash,
supplyHash: repayHash,
executeApproveAndSupply: executeRepay,
reset: resetRepay,
} = useLendingSupply();
const BORROW_TOAST_ID = 'lending-borrow-tx';
const REPAY_TOAST_ID = 'lending-repay-tx';
// Borrow 交易提交 toast
useEffect(() => {
if (borrowHash && borrowStatus === 'withdrawing') {
toast.loading(t("repay.toast.borrowSubmitted"), {
id: BORROW_TOAST_ID,
description: t("repay.toast.borrowingNow"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(borrowHash), '_blank') },
});
}
}, [borrowHash, borrowStatus]);
// Repay approve 交易提交 toast
useEffect(() => {
if (repayApproveHash && repayStatus === 'approving') {
toast.loading(t("repay.toast.approvalSubmitted"), {
id: REPAY_TOAST_ID,
description: t("repay.toast.waitingConfirmation"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(repayApproveHash), '_blank') },
});
}
}, [repayApproveHash, repayStatus]);
// Repay 交易提交 toast
useEffect(() => {
if (repayHash && repayStatus === 'supplying') {
toast.loading(t("repay.toast.repaySubmitted"), {
id: REPAY_TOAST_ID,
description: t("repay.toast.repayingNow"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(repayHash), '_blank') },
});
}
}, [repayHash, repayStatus]);
// Notify backend after borrow
useEffect(() => {
if (borrowStatus === 'success' && borrowHash && amount) {
toast.success(t("repay.toast.borrowSuccess"), {
id: BORROW_TOAST_ID,
description: t("repay.toast.borrowSuccessDesc"),
duration: 5000,
});
notifyLendingBorrow(amount, borrowHash);
setTimeout(() => refetchBorrow(), 2000);
setAmount('');
setTimeout(resetBorrow, 3000);
}
}, [borrowStatus]);
// Notify backend after repay
useEffect(() => {
if (repayStatus === 'success' && repayHash && amount) {
toast.success(t("repay.toast.repaySuccess"), {
id: REPAY_TOAST_ID,
description: t("repay.toast.repaySuccessDesc"),
duration: 5000,
});
notifyLendingRepay(amount, repayHash);
setTimeout(() => { refetchBorrow(); refetchSupply(); }, 2000);
setAmount('');
setTimeout(resetRepay, 3000);
}
}, [repayStatus]);
// 错误提示
useEffect(() => {
if (borrowError) {
if (borrowError === 'Transaction cancelled') {
toast.dismiss(BORROW_TOAST_ID);
} else {
toast.error(t("repay.toast.borrowFailed"), { id: BORROW_TOAST_ID, duration: 5000 });
}
}
}, [borrowError]);
useEffect(() => {
if (repayError) {
if (repayError === 'Transaction cancelled') {
toast.dismiss(REPAY_TOAST_ID);
} else {
toast.error(t("repay.toast.repayFailed"), { id: REPAY_TOAST_ID, duration: 5000 });
}
}
}, [repayError]);
// 从产品 API 获取 USDC token 信息,优先从合约地址读取精度
const usdcToken = useTokenBySymbol('USDC');
const { chainId } = useAccount();
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined;
const usdcInputDecimals = useTokenDecimalsFromAddress(usdcToken?.contractAddress, usdcToken?.decimals ?? 18);
// 最小借贷数量
const { data: baseBorrowMinRaw } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'baseBorrowMin',
query: { enabled: !!lendingProxyAddress },
});
const baseBorrowMin = baseBorrowMinRaw != null
? parseFloat(formatUnits(baseBorrowMinRaw as bigint, usdcInputDecimals))
: 0;
const usdcDisplayDecimals = Math.min(usdcInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const isBelowMin = activeTab === 'borrow' && isValidAmount(amount) && baseBorrowMin > 0 && parseFloat(amount) < baseBorrowMin;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > usdcDisplayDecimals) return;
const maxBalance = activeTab === 'borrow' ? parseFloat(formattedAvailable) : parseFloat(borrowedBalance);
if (parseFloat(value) > maxBalance) {
setAmount(truncateDecimals(activeTab === 'borrow' ? formattedAvailable : borrowedBalance, usdcDisplayDecimals));
return;
}
setAmount(value);
};
const handleAction = () => {
if (!amount || parseFloat(amount) <= 0) return;
if (activeTab === 'borrow') {
executeBorrow(amount);
} else {
executeRepay(amount);
}
};
const handleMax = () => {
if (activeTab === 'repay') {
setAmount(truncateDecimals(borrowedBalance, usdcDisplayDecimals));
} else if (activeTab === 'borrow') {
setAmount(truncateDecimals(formattedAvailable, usdcDisplayDecimals));
}
};
const isLoading = isBorrowing || isRepaying;
const currentStatus = activeTab === 'borrow' ? borrowStatus : repayStatus;
const getButtonLabel = () => {
if (!isConnected) return t("common.connectWallet");
if (currentStatus === 'approving') return t("repay.approvingUsdc");
if (currentStatus === 'approved') return t("repay.confirmedProcessing");
if (currentStatus === 'withdrawing') return t("repay.borrowing");
if (currentStatus === 'supplying') return t("repay.repaying");
if (currentStatus === 'success') return t("common.success");
if (currentStatus === 'error') return t("common.failed");
if (amount && !isValidAmount(amount)) return t("common.invalidAmount");
if (isBelowMin) return t("repay.minBorrow").replace('{{amount}}', baseBorrowMin.toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals }));
return activeTab === 'borrow' ? t("repay.borrow") : t("repay.repay");
};
return (
<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 flex-1 shadow-md">
{/* Title */}
<h3 className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{t("repay.borrowDebt")}
</h3>
{/* USDC Balance Info */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-full overflow-hidden shadow-md flex-shrink-0">
<Image
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
alt="USDC"
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-baseline gap-1">
<span className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em] truncate">
{!mounted ? '--' : parseFloat(truncateDecimals(borrowedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400 flex-shrink-0">USDC</span>
</div>
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("repay.borrowed")}
</span>
</div>
</div>
<div className="flex flex-col items-end flex-shrink-0">
<div className="flex items-baseline gap-1">
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 whitespace-nowrap">
{!mounted ? '--' : parseFloat(truncateDecimals(formattedAvailable, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-caption-tiny font-bold text-[#10b981] dark:text-green-400 flex-shrink-0">USDC</span>
</div>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 whitespace-nowrap">
{t("repay.availableToBorrow")}
</span>
</div>
</div>
{/* Tab Switcher */}
<div className="flex rounded-xl bg-fill-secondary-click dark:bg-gray-700 p-1">
<button
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
activeTab === 'borrow'
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
: 'text-text-tertiary dark:text-gray-400'
}`}
onClick={() => { setActiveTab('borrow'); setAmount(''); resetBorrow(); }}
>
{t("repay.borrow")}
</button>
<button
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
activeTab === 'repay'
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
: 'text-text-tertiary dark:text-gray-400'
}`}
onClick={() => { setActiveTab('repay'); setAmount(''); resetRepay(); }}
>
{t("repay.repay")}
</button>
</div>
{/* Amount Input */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{t("repay.amount")} (USDC)
</span>
{activeTab === 'borrow' && mounted && available > 0 && (
<button
onClick={handleMax}
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
>
{t("common.max")}: {parseFloat(truncateDecimals(formattedAvailable, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} USDC
</button>
)}
{activeTab === 'repay' && mounted && parseFloat(borrowedBalance) > 0 && (
<button
onClick={handleMax}
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
>
{t("common.max")}: {parseFloat(truncateDecimals(borrowedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} USDC
</button>
)}
</div>
<div className="flex items-center bg-bg-subtle dark:bg-gray-700 rounded-xl px-4 h-[52px] gap-2">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
className="flex-1 bg-transparent text-body-default font-bold text-text-primary dark:text-white outline-none"
/>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400">
USDC
</span>
</div>
</div>
{/* Action Button */}
<Button
isDisabled={!mounted || (isConnected && (!isValidAmount(amount) || isBelowMin || isLoading || currentStatus === 'success'))}
color="default"
className={buttonStyles({ intent: "theme" })}
onPress={() => !isConnected ? open() : handleAction()}
>
{!mounted ? 'Loading...' : getButtonLabel()}
</Button>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
import { useYTPrice } from "@/hooks/useCollateral";
import { fetchLendingStats } from "@/lib/api/lending";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
const GRADIENT_COLORS: Record<string, string> = {
'YT-A': 'linear-gradient(135deg, #FF8904 0%, #F54900 100%)',
'YT-B': 'linear-gradient(135deg, #00BBA7 0%, #007A55 100%)',
'YT-C': 'linear-gradient(135deg, #667EEA 0%, #764BA2 100%)',
};
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #6B7280 0%, #374151 100%)';
interface RepayHeaderProps {
tokenType: string;
}
export default function RepayHeader({ tokenType }: RepayHeaderProps) {
const { t } = useApp();
const [mounted, setMounted] = useState(false);
const [availableUsd, setAvailableUsd] = useState<string | null>(null);
const ytToken = useTokenBySymbol(tokenType);
const { formattedPrice, isLoading: isPriceLoading } = useYTPrice(ytToken);
useEffect(() => {
setMounted(true);
fetchLendingStats().then((stats) => {
if (stats) {
const available = stats.totalSuppliedUsd - stats.totalBorrowedUsd;
setAvailableUsd(available > 0 ? available.toLocaleString('en-US', { maximumFractionDigits: 0 }) : '0');
}
});
}, []);
const displayPrice = !mounted || isPriceLoading ? '--' : `$${parseFloat(formattedPrice).toFixed(4)}`;
const displayAvailable = availableUsd !== null ? `$${availableUsd}` : '--';
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 px-4 md:px-6 py-4 md:py-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
{/* Left Section - Token Icons and Title */}
<div className="flex items-center gap-2">
{/* Overlapping Token Icons */}
<div className="flex items-center relative">
<div
className="w-[52px] h-[52px] rounded-full flex items-center justify-center text-white text-xs font-bold shadow-md relative z-10 overflow-hidden"
style={{ background: GRADIENT_COLORS[tokenType] ?? DEFAULT_GRADIENT }}
>
{ytToken?.iconUrl ? (
<Image src={ytToken.iconUrl} alt={tokenType} width={52} height={52} className="w-full h-full object-cover" />
) : (
tokenType.replace('YT-', '')
)}
</div>
<Image
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
alt="USDC"
width={52}
height={52}
className="relative -ml-3"
/>
</div>
{/* Title and Subtitle */}
<div className="flex flex-col gap-1 ml-2">
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">
{tokenType} / USDC
</h1>
<p className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("repay.supplyToBorrow")}
</p>
</div>
</div>
{/* Right Section - Stats */}
<div className="flex items-center justify-between md:w-[262px]">
{/* Price */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("repay.price")}
</span>
<span className="text-heading-h3 font-bold text-[#10b981] dark:text-green-400 leading-[130%] tracking-[-0.005em]">
{displayPrice}
</span>
</div>
{/* Available */}
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("repay.available")}
</span>
<span className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{displayAvailable}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useApp } from "@/contexts/AppContext";
import { fetchProducts, fetchProductDetail } from "@/lib/api/fundmarket";
interface RepayPoolStatsProps {
tokenType: string;
}
export default function RepayPoolStats({ tokenType }: RepayPoolStatsProps) {
const { t } = useApp();
const { data: products = [] } = useQuery({
queryKey: ['token-list'],
queryFn: () => fetchProducts(true),
staleTime: 5 * 60 * 1000,
});
const product = products.find(p => p.tokenSymbol === tokenType);
const { data: detail } = useQuery({
queryKey: ['product-detail', product?.id],
queryFn: () => fetchProductDetail(product!.id),
enabled: !!product?.id,
staleTime: 5 * 60 * 1000,
});
const tvlDisplay = detail?.tvlUsd != null
? `$${detail.tvlUsd.toLocaleString('en-US', { maximumFractionDigits: 0 })}`
: '--';
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
{/* Left Card - Total Value Locked and Utilization */}
<div className="bg-white/50 dark:bg-gray-800/50 rounded-2xl border border-border-normal dark:border-gray-700 px-4 md:px-6 py-4 flex items-center justify-between md:h-[98px]">
{/* Total Value Locked */}
<div className="flex flex-col gap-1">
<span className="text-[12px] font-bold text-[#90a1b9] dark:text-gray-400 leading-4 uppercase">
{t("repay.totalValueLocked")}
</span>
<span className="text-[20px] font-bold text-[#0f172b] dark:text-white leading-7 tracking-[-0.45px]">
{tvlDisplay}
</span>
</div>
{/* Utilization */}
<div className="flex flex-col gap-1">
<span className="text-[12px] font-bold text-[#90a1b9] dark:text-gray-400 leading-4 uppercase text-right">
{t("repay.utilization")}
</span>
<span className="text-[20px] font-bold text-[#0f172b] dark:text-white leading-7 tracking-[-0.45px] text-right">
42.8%
</span>
</div>
</div>
{/* Right Card - Reward Multiplier */}
<div className="bg-white/50 dark:bg-gray-800/50 rounded-2xl border border-border-normal dark:border-gray-700 px-4 md:px-6 py-4 flex items-center justify-between md:h-[98px]">
{/* Reward Multiplier */}
<div className="flex flex-col gap-1">
<span className="text-[12px] font-bold text-[#90a1b9] dark:text-gray-400 leading-4 uppercase">
{t("repay.rewardMultiplier")}
</span>
<span className="text-[20px] font-bold text-[#0f172b] dark:text-white leading-7 tracking-[-0.45px]">
2.5x
</span>
</div>
{/* Overlapping Circles */}
<div className="relative w-20 h-8">
{/* Green Circle */}
<div className="absolute left-0 top-0 w-8 h-8 rounded-full bg-[#00bc7d] border-2 border-white dark:border-gray-900" />
{/* Blue Circle */}
<div className="absolute left-6 top-0 w-8 h-8 rounded-full bg-[#2b7fff] border-2 border-white dark:border-gray-900" />
{/* Gray Circle with +3 */}
<div className="absolute left-12 top-0 w-8 h-8 rounded-full bg-[#e2e8f0] dark:bg-gray-600 border-2 border-white dark:border-gray-900 flex items-center justify-center">
<span className="text-[10px] font-bold text-[#45556c] dark:text-gray-300 leading-[15px] tracking-[0.12px]">
+3
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
import { useLTV, useBorrowBalance } from "@/hooks/useCollateral";
import { useSupplyAPY } from "@/hooks/useHealthFactor";
export default function RepayStats() {
const { t } = useApp();
const [mounted, setMounted] = useState(false);
const { ltvRaw } = useLTV();
const { formattedBalance: borrowedBalance } = useBorrowBalance();
const { apr: supplyApr } = useSupplyAPY();
useEffect(() => { setMounted(true); }, []);
const LIQUIDATION_LTV = 75;
const hasPosition = mounted && parseFloat(borrowedBalance) > 0;
const healthPct = hasPosition ? Math.max(0, Math.min(100, (1 - ltvRaw / LIQUIDATION_LTV) * 100)) : 0;
const getHealthLabel = () => {
if (!hasPosition) return 'No Position';
if (ltvRaw < 50) return `Safe ${healthPct.toFixed(0)}%`;
if (ltvRaw < 65) return `Warning ${healthPct.toFixed(0)}%`;
return `At Risk ${healthPct.toFixed(0)}%`;
};
const getHealthColor = () => {
if (!hasPosition) return 'text-text-tertiary dark:text-gray-400';
if (ltvRaw < 50) return 'text-[#10b981] dark:text-green-400';
if (ltvRaw < 65) return 'text-[#ff6900] dark:text-orange-400';
return 'text-[#ef4444] dark:text-red-400';
};
const getBarGradient = () => {
if (!hasPosition) return undefined;
if (ltvRaw < 50) return 'linear-gradient(90deg, rgba(0, 213, 190, 1) 0%, rgba(0, 188, 125, 1) 100%)';
if (ltvRaw < 65) return 'linear-gradient(90deg, #ff6900 0%, #ff9900 100%)';
return 'linear-gradient(90deg, #ef4444 0%, #dc2626 100%)';
};
// Net APR = getSupplyRate() directly (already represents net yield)
const netApr = supplyApr > 0 ? supplyApr.toFixed(4) : null;
return (
<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-8 flex-1 shadow-md">
{/* Stats Info */}
<div className="flex flex-col gap-6">
{/* NET APR */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Image src="/components/repay/icon0.svg" alt="" width={18} height={18} className="flex-shrink-0" />
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("repay.netApr")}
</span>
</div>
<span className={`text-body-small font-bold leading-[150%] whitespace-nowrap flex-shrink-0 ${netApr !== null && parseFloat(netApr) >= 0 ? 'text-[#10b981] dark:text-green-400' : 'text-[#ef4444]'}`}>
{netApr !== null ? `${parseFloat(netApr) >= 0 ? '+' : ''}${netApr}%` : '--'}
</span>
</div>
{/* Position Health */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Image src="/components/repay/icon2.svg" alt="" width={18} height={18} className="flex-shrink-0" />
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("repay.positionHealth")}
</span>
</div>
<span className={`text-body-small font-bold leading-[150%] whitespace-nowrap flex-shrink-0 ${getHealthColor()}`}>
{!mounted ? '--' : getHealthLabel()}
</span>
</div>
</div>
{/* Progress Bar */}
<div className="w-full">
<div className="w-full h-2 bg-[#f3f4f6] dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-2 rounded-full transition-all duration-500"
style={{
width: `${hasPosition ? healthPct : 0}%`,
background: getBarGradient() || '#e5e7eb',
boxShadow: hasPosition && ltvRaw < 50 ? "0px 0px 15px 0px rgba(16, 185, 129, 0.3)" : undefined,
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount } from "wagmi";
import { useLendingCollateral, useCollateralBalance, useWithdrawCollateral, useYTWalletBalance } from "@/hooks/useLendingCollateral";
import { useYTPrice } from "@/hooks/useCollateral";
import { notifySupplyCollateral, notifyWithdrawCollateral } from "@/lib/api/lending";
import { useAppKit } from "@reown/appkit/react";
import { toast } from "sonner";
import { getTxUrl } from "@/lib/contracts";
import { useTokenBySymbol } from "@/hooks/useTokenBySymbol";
const GRADIENT_COLORS: Record<string, string> = {
'YT-A': 'linear-gradient(135deg, #FF8904 0%, #F54900 100%)',
'YT-B': 'linear-gradient(135deg, #00BBA7 0%, #007A55 100%)',
'YT-C': 'linear-gradient(135deg, #667EEA 0%, #764BA2 100%)',
};
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #6B7280 0%, #374151 100%)';
interface RepaySupplyCollateralProps {
tokenType: string;
onCollateralChanged?: () => void;
}
export default function RepaySupplyCollateral({ tokenType, onCollateralChanged }: RepaySupplyCollateralProps) {
const { t } = useApp();
const { isConnected } = useAccount();
const { open } = useAppKit();
const [mounted, setMounted] = useState(false);
const [activeTab, setActiveTab] = useState<'deposit' | 'withdraw'>('deposit');
const [amount, setAmount] = useState('');
useEffect(() => { setMounted(true); }, []);
// On-chain data
const ytToken = useTokenBySymbol(tokenType);
const { formattedBalance, refetch: refetchBalance } = useCollateralBalance(ytToken);
const { formattedBalance: walletBalance, refetch: refetchWalletBalance } = useYTWalletBalance(ytToken);
const { formattedPrice } = useYTPrice(ytToken);
const collateralUsd = (parseFloat(formattedBalance) * parseFloat(formattedPrice)).toFixed(2);
// Deposit hook
const {
status: depositStatus,
error: depositError,
isLoading: isDepositing,
approveHash: depositApproveHash,
supplyHash,
executeApproveAndSupply,
reset: resetDeposit,
} = useLendingCollateral();
// Withdraw hook
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawing,
withdrawHash,
executeWithdrawCollateral,
reset: resetWithdraw,
} = useWithdrawCollateral();
const DEPOSIT_TOAST_ID = 'collateral-deposit-tx';
const WITHDRAW_TOAST_ID = 'collateral-withdraw-tx';
// Deposit approve 交易提交 toast
useEffect(() => {
if (depositApproveHash && depositStatus === 'approving') {
toast.loading(t("repay.toast.approvalSubmitted"), {
id: DEPOSIT_TOAST_ID,
description: t("repay.toast.waitingConfirmation"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(depositApproveHash), '_blank') },
});
}
}, [depositApproveHash, depositStatus]);
// Deposit 交易提交 toast
useEffect(() => {
if (supplyHash && depositStatus === 'supplying') {
toast.loading(t("repay.toast.depositSubmitted"), {
id: DEPOSIT_TOAST_ID,
description: t("repay.toast.depositingNow"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(supplyHash), '_blank') },
});
}
}, [supplyHash, depositStatus]);
// Withdraw 交易提交 toast
useEffect(() => {
if (withdrawHash && withdrawStatus === 'withdrawing') {
toast.loading(t("repay.toast.withdrawSubmitted"), {
id: WITHDRAW_TOAST_ID,
description: t("repay.toast.withdrawingNow"),
action: { label: t("repay.toast.viewTx"), onClick: () => window.open(getTxUrl(withdrawHash), '_blank') },
});
}
}, [withdrawHash, withdrawStatus]);
// Notify backend after successful tx
useEffect(() => {
if (depositStatus === 'success' && supplyHash && amount) {
toast.success(t("repay.toast.depositSuccess"), {
id: DEPOSIT_TOAST_ID,
description: t("repay.toast.depositSuccessDesc"),
duration: 5000,
});
notifySupplyCollateral(tokenType, amount, supplyHash);
refetchBalance();
refetchWalletBalance();
onCollateralChanged?.();
setAmount('');
setTimeout(resetDeposit, 3000);
}
}, [depositStatus]);
useEffect(() => {
if (withdrawStatus === 'success' && withdrawHash && amount) {
toast.success(t("repay.toast.withdrawSuccess"), {
id: WITHDRAW_TOAST_ID,
description: t("repay.toast.withdrawSuccessDesc"),
duration: 5000,
});
notifyWithdrawCollateral(tokenType, amount, withdrawHash);
refetchBalance();
setAmount('');
setTimeout(resetWithdraw, 3000);
}
}, [withdrawStatus]);
// 错误提示
useEffect(() => {
if (depositError) {
if (depositError === 'Transaction cancelled') {
toast.dismiss(DEPOSIT_TOAST_ID);
} else {
toast.error(t("repay.toast.depositFailed"), { id: DEPOSIT_TOAST_ID, duration: 5000 });
}
}
}, [depositError]);
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.dismiss(WITHDRAW_TOAST_ID);
} else {
toast.error(t("repay.toast.withdrawFailed"), { id: WITHDRAW_TOAST_ID, duration: 5000 });
}
}
}, [withdrawError]);
const inputDecimals = ytToken?.onChainDecimals ?? ytToken?.decimals ?? 18;
const displayDecimals = Math.min(inputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > displayDecimals) return;
const maxBalance = activeTab === 'deposit' ? parseFloat(walletBalance) : parseFloat(formattedBalance);
if (parseFloat(value) > maxBalance) {
setAmount(truncateDecimals(activeTab === 'deposit' ? walletBalance : formattedBalance, displayDecimals));
return;
}
setAmount(value);
};
const handleAction = () => {
if (!amount || parseFloat(amount) <= 0 || !ytToken) return;
if (activeTab === 'deposit') {
executeApproveAndSupply(ytToken, amount);
} else {
executeWithdrawCollateral(ytToken, amount);
}
};
const handleMax = () => {
if (activeTab === 'deposit') {
setAmount(truncateDecimals(walletBalance, displayDecimals));
} else {
setAmount(truncateDecimals(formattedBalance, displayDecimals));
}
};
const isLoading = isDepositing || isWithdrawing;
const currentStatus = activeTab === 'deposit' ? depositStatus : withdrawStatus;
const getButtonLabel = () => {
if (!isConnected) return t("common.connectWallet");
if (currentStatus === 'approving') return t("common.approving");
if (currentStatus === 'approved') return t("repay.confirmedDepositing");
if (currentStatus === 'supplying') return t("repay.depositing");
if (currentStatus === 'withdrawing') return t("repay.withdrawing");
if (currentStatus === 'success') return t("common.success");
if (currentStatus === 'error') return t("common.failed");
if (amount && !isValidAmount(amount)) return t("common.invalidAmount");
return activeTab === 'deposit' ? t("repay.deposit") : t("repay.withdraw");
};
return (
<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 flex-1 shadow-md">
{/* Title */}
<h3 className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{t("repay.supplyCollateral")}
</h3>
{/* Token Info and Value */}
<div className="flex items-center gap-3 min-w-0">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold shadow-md overflow-hidden flex-shrink-0"
style={{ background: GRADIENT_COLORS[tokenType] ?? DEFAULT_GRADIENT }}
>
{ytToken?.iconUrl ? (
<Image src={ytToken.iconUrl} alt={tokenType} width={40} height={40} className="w-full h-full object-cover" />
) : (
tokenType.replace('YT-', '')
)}
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-baseline gap-1">
<span className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em] truncate">
{!mounted ? '--' : parseFloat(formattedBalance).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })}
</span>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400 flex-shrink-0">{tokenType}</span>
</div>
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{!mounted ? '--' : `$${parseFloat(collateralUsd).toLocaleString('en-US', { maximumFractionDigits: 2 })}`}
</span>
</div>
</div>
{/* Tab Switcher */}
<div className="flex rounded-xl bg-fill-secondary-click dark:bg-gray-700 p-1">
<button
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
activeTab === 'deposit'
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
: 'text-text-tertiary dark:text-gray-400'
}`}
onClick={() => { setActiveTab('deposit'); setAmount(''); resetDeposit(); }}
>
{t("repay.deposit")}
</button>
<button
className={`flex-1 rounded-lg py-2 text-body-small font-bold transition-all ${
activeTab === 'withdraw'
? 'bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm'
: 'text-text-tertiary dark:text-gray-400'
}`}
onClick={() => { setActiveTab('withdraw'); setAmount(''); resetWithdraw(); }}
>
{t("repay.withdraw")}
</button>
</div>
{/* Amount Input */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{t("repay.amount")}
</span>
{activeTab === 'deposit' && mounted && (
<button
onClick={handleMax}
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
>
{t("common.max")}: {parseFloat(walletBalance).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })} {tokenType}
</button>
)}
{activeTab === 'withdraw' && mounted && (
<button
onClick={handleMax}
className="text-caption-tiny font-bold text-[#ff6900] hover:opacity-80"
>
{t("common.max")}: {parseFloat(formattedBalance).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })} {tokenType}
</button>
)}
</div>
<div className="flex items-center bg-bg-subtle dark:bg-gray-700 rounded-xl px-4 h-[52px] gap-2">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
className="flex-1 bg-transparent text-body-default font-bold text-text-primary dark:text-white outline-none"
/>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400">
{tokenType}
</span>
</div>
</div>
{/* Action Button */}
<Button
isDisabled={!mounted || (isConnected && (!isValidAmount(amount) || isLoading || currentStatus === 'success'))}
color="default"
className={buttonStyles({ intent: "theme" })}
onPress={() => !isConnected ? open() : handleAction()}
>
{!mounted ? 'Loading...' : getButtonLabel()}
</Button>
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { useQuery } from "@tanstack/react-query";
import { useChainId } from "wagmi";
import * as echarts from "echarts";
import { fetchLendingAPYHistory } from "@/lib/api/lending";
type Period = "1W" | "1M" | "1Y";
export default function SupplyContent() {
const { t } = useApp();
const chainId = useChainId();
const [selectedPeriod, setSelectedPeriod] = useState<Period>("1W");
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { data: apyData, isLoading } = useQuery({
queryKey: ["lending-apy-history", selectedPeriod, chainId],
queryFn: () => fetchLendingAPYHistory(selectedPeriod, chainId),
staleTime: 5 * 60 * 1000,
});
const chartData = apyData?.history.map((p) => p.supply_apy) ?? [];
const xLabels = apyData?.history.map((p) => {
const d = new Date(p.time);
if (selectedPeriod === "1W") return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:00`;
if (selectedPeriod === "1M") return `${d.getMonth() + 1}/${d.getDate()}`;
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
}) ?? [];
const currentAPY = apyData?.current_supply_apy ?? 0;
const apyChange = apyData?.apy_change ?? 0;
// 初始化 & 销毁(只在 mount/unmount 执行)
useEffect(() => {
if (!chartRef.current) return;
chartInstance.current = echarts.init(chartRef.current);
const observer = new ResizeObserver(() => chartInstance.current?.resize());
observer.observe(chartRef.current);
return () => {
observer.disconnect();
chartInstance.current?.dispose();
chartInstance.current = null;
};
}, []);
// 更新图表数据period/data 变化时)
useEffect(() => {
if (!chartInstance.current) return;
chartInstance.current.setOption({
grid: { left: 0, right: 0, top: 10, bottom: 0 },
tooltip: {
trigger: "axis",
show: true,
confine: true,
backgroundColor: "rgba(17, 24, 39, 0.9)",
borderColor: "#374151",
textStyle: { color: "#f9fafb", fontSize: 12, fontWeight: 500 },
formatter: (params: any) => {
const d = params[0];
return `<div style="padding: 4px 8px;">
<span style="color: #9ca3af; font-size: 11px;">${d.axisValueLabel}</span><br/>
<span style="color: #10b981; font-weight: 600; font-size: 14px;">${Number(d.value).toFixed(4)}%</span>
</div>`;
},
},
xAxis: {
type: "category",
data: xLabels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: xLabels.length > 0 && xLabels.length <= 24,
color: "#9ca3af",
fontSize: 10,
fontWeight: 500,
interval: Math.max(0, Math.floor(xLabels.length / 7) - 1),
},
},
yAxis: {
type: "value",
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
series: [
{
data: chartData,
type: "line",
smooth: true,
symbol: chartData.length <= 30 ? "circle" : "none",
symbolSize: 4,
lineStyle: { color: "#10b981", width: 2 },
itemStyle: { color: "#10b981" },
areaStyle: {
color: {
type: "linear",
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: "rgba(16, 185, 129, 0.3)" },
{ offset: 1, color: "rgba(16, 185, 129, 0)" },
],
},
},
},
],
}, true);
}, [chartData, xLabels]);
return (
<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-4 w-full shadow-md">
{/* Header */}
<div className="flex items-center gap-3 flex-shrink-0">
<Image src="/assets/tokens/usd-coin-usdc-logo-10.svg" alt="USDC" width={32} height={32} />
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">
{t("supply.usdcLendPool")}
</h1>
</div>
{/* Historical APY Section */}
<div className="flex items-start justify-between flex-shrink-0">
{/* Left - APY Display */}
<div className="flex flex-col gap-1">
<span className="text-[12px] font-bold text-text-tertiary dark:text-gray-400 leading-[16px] tracking-[1.2px] uppercase">
{t("supply.historicalApy")}
</span>
<div className="flex items-center gap-2">
<span className="text-heading-h2 font-bold text-[#10b981] dark:text-green-400 leading-[130%] tracking-[-0.01em]">
{isLoading ? "--" : currentAPY > 0 ? `${currentAPY.toFixed(4)}%` : "--"}
</span>
{!isLoading && apyData && apyData.history.length > 1 && (
<div className={`rounded-full px-2 py-0.5 flex items-center justify-center ${apyChange >= 0 ? "bg-[#e1f8ec] dark:bg-green-900/30" : "bg-red-50 dark:bg-red-900/30"}`}>
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${apyChange >= 0 ? "text-[#10b981] dark:text-green-400" : "text-red-500 dark:text-red-400"}`}>
{apyChange >= 0 ? "+" : ""}{apyChange.toFixed(4)}%
</span>
</div>
)}
</div>
</div>
{/* Right - Period Selector */}
<div className="bg-[#F9FAFB] dark:bg-gray-700 rounded-xl p-1 flex items-center gap-1">
{(["1W", "1M", "1Y"] as Period[]).map((p) => (
<Button
key={p}
size="sm"
variant={selectedPeriod === p ? "solid" : "light"}
onPress={() => setSelectedPeriod(p)}
>
{p}
</Button>
))}
</div>
</div>
{/* Chart Section — 始终渲染 div避免 echarts 实例因 DOM 销毁而失效 */}
<div className="w-full relative" style={{ height: "260px" }}>
<div
ref={chartRef}
className="w-full h-full"
style={{ background: "linear-gradient(0deg, rgba(16, 185, 129, 0.1) 0%, transparent 100%)" }}
/>
{/* Loading 遮罩 */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-gray-800/60">
<span className="text-sm text-text-tertiary dark:text-gray-400">{t("common.loading")}</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,331 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useUSDCBalance } from '@/hooks/useBalance';
import { useLendingSupply, useSuppliedBalance } from '@/hooks/useLendingSupply';
import { getTxUrl } from '@/lib/contracts';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useHealthFactor, useSupplyAPY } from '@/hooks/useHealthFactor';
import { toast } from "sonner";
import { useWalletStatus } from '@/hooks/useWalletStatus';
import TokenSelector from '@/components/common/TokenSelector';
import { useAppKit } from "@reown/appkit/react";
import { Token } from '@/lib/api/tokens';
type StablecoinType = 'USDC' | 'USDT';
export default function SupplyPanel() {
const { t } = useApp();
const [amount, setAmount] = useState("");
// 稳定币选择(用于余额显示)
const [stablecoin, setStablecoin] = useState<StablecoinType>('USDC');
const [stablecoinObj, setStablecoinObj] = useState<Token | undefined>();
// 使用统一的钱包状态 Hook
const { address, isConnected, chainId, mounted } = useWalletStatus();
const { open } = useAppKit();
// 处理稳定币选择
const handleStablecoinSelect = useCallback((token: Token) => {
setStablecoinObj(token);
setStablecoin(token.symbol as StablecoinType);
setAmount(""); // 清空输入金额
}, []);
// 优先从产品合约地址读取精度,失败时回退到 token.decimals
const usdcInputDecimals = useTokenDecimalsFromAddress(
stablecoinObj?.contractAddress,
stablecoinObj?.decimals ?? 18
);
const usdcDisplayDecimals = Math.min(usdcInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > usdcDisplayDecimals) return;
if (parseFloat(value) > parseFloat(usdcBalance)) { setAmount(truncateDecimals(usdcBalance, usdcDisplayDecimals)); return; }
setAmount(value);
};
const { formattedBalance: usdcBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useUSDCBalance();
const { formattedBalance: suppliedBalance, refetch: refetchSupplied } = useSuppliedBalance();
// 根据选择的 token 类型决定使用哪个 hook
// 目前只支持 USDC supplyYT token 选择仅用于显示
const {
status: supplyStatus,
error: supplyError,
isLoading: isSupplyLoading,
approveHash,
supplyHash,
executeApproveAndSupply,
reset: resetSupply,
} = useLendingSupply();
// 健康因子和 APY
const {
formattedHealthFactor,
status: healthStatus,
utilization,
isLoading: isHealthLoading,
refetch: refetchHealthFactor,
} = useHealthFactor();
const { apy } = useSupplyAPY();
const SUPPLY_TOAST_ID = 'lending-supply-tx';
// Approve 交易提交
useEffect(() => {
if (approveHash && supplyStatus === 'approving') {
toast.loading(t("supply.toast.approvalSubmitted"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.waitingConfirmation"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(approveHash), '_blank'),
},
});
}
}, [approveHash, supplyStatus]);
// Supply 交易提交
useEffect(() => {
if (supplyHash && supplyStatus === 'supplying') {
toast.loading(t("supply.toast.supplySubmitted"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.supplyingNow"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(supplyHash), '_blank'),
},
});
}
}, [supplyHash, supplyStatus]);
// 成功后刷新余额
useEffect(() => {
if (supplyStatus === 'success') {
toast.success(t("supply.toast.suppliedSuccess"), {
id: SUPPLY_TOAST_ID,
description: t("supply.toast.suppliedSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchSupplied();
refetchHealthFactor();
const timer = setTimeout(() => { resetSupply(); setAmount(''); }, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [supplyStatus]);
// 错误提示
useEffect(() => {
if (supplyError) {
if (supplyError === 'Transaction cancelled') {
toast.dismiss(SUPPLY_TOAST_ID);
} else {
toast.error(t("supply.toast.supplyFailed"), {
id: SUPPLY_TOAST_ID,
duration: 5000,
});
}
}
}, [supplyError]);
return (
<div className="p-6 flex flex-col gap-6 flex-1">
{/* Token Balance & Supplied */}
<div className="flex flex-col gap-4">
{/* Token Balance */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-2">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.tokenBalance")}
</span>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-1 flex-1">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
{/* 稳定币选择器 */}
<div className="flex-shrink-0">
<TokenSelector
selectedToken={stablecoinObj}
onSelect={handleStablecoinSelect}
filterTypes={['stablecoin']}
defaultSymbol={stablecoin}
/>
</div>
</div>
</div>
{/* Supplied - 同步显示选中的稳定币 */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.supplied")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : parseFloat(suppliedBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{stablecoin}
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : parseFloat(suppliedBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}
</span>
</div>
</div>
{/* Deposit Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-4">
{/* Deposit Label and Available */}
<div className="flex items-center gap-2">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("supply.deposit")}
</span>
<div className="flex items-center gap-2 ml-auto">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{!mounted ? `0 ${stablecoin}` : isBalanceLoading ? '...' : `${parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} ${stablecoin}`}
</span>
</div>
<Button
size="sm"
color="default"
variant="solid"
className={buttonStyles({ intent: "max" })}
onPress={() => setAmount(truncateDecimals(usdcBalance, usdcDisplayDecimals))}
>
{t("supply.max")}
</Button>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-4 h-12">
{/* Amount Input */}
<div className="flex flex-col items-end flex-1">
<input
type="text" inputMode="decimal"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder="0.00"
className="text-heading-h3 font-bold text-text-input-box dark:text-gray-500 leading-[130%] tracking-[-0.005em] text-right bg-transparent outline-none w-full"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
--
</span>
</div>
</div>
</div>
{/* Health Factor */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.healthFactor")}: {isHealthLoading ? '...' : formattedHealthFactor}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{utilization.toFixed(1)}% {t("supply.utilization")}
</span>
</div>
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${
healthStatus === 'safe' ? 'text-[#10b981] dark:text-green-400' :
healthStatus === 'warning' ? 'text-[#ffb933] dark:text-yellow-400' :
healthStatus === 'danger' ? 'text-[#ff6900] dark:text-orange-400' :
'text-[#ef4444] dark:text-red-400'
}`}>
{healthStatus === 'safe' ? t("supply.safe") :
healthStatus === 'warning' ? t("supply.warning") :
healthStatus === 'danger' ? t("supply.danger") :
t("supply.critical")}
</span>
</div>
{/* Rainbow Gradient Progress Bar */}
<div className="relative w-full h-2.5 rounded-full overflow-hidden">
{/* Background Rainbow Gradient */}
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
}}
/>
{/* Active Progress with Rainbow Gradient */}
<div
className="absolute left-0 top-0 h-full rounded-full transition-all duration-500 ease-out"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
width: `${Math.min(utilization, 100)}%`,
boxShadow: utilization > 0 ? '0 0 8px rgba(59, 130, 246, 0.5)' : 'none',
}}
/>
</div>
</div>
{/* Estimated Returns */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-2">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-300 leading-[150%]">
{t("supply.estimatedReturns")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.estApy")}
</span>
<span className="text-body-small font-bold text-[#ff6900] dark:text-orange-400 leading-[150%]">
{apy > 0 ? `${apy.toFixed(2)}%` : '--'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.estReturnsYear")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 leading-[150%]">
{amount && apy > 0
? `~ $${(parseFloat(amount) * apy / 100).toFixed(2)}`
: '~ $0.00'}
</span>
</div>
</div>
{/* Supply Button */}
<Button
isDisabled={!mounted || (isConnected && (!isValidAmount(amount) || isSupplyLoading))}
color="default"
variant="solid"
className={`${buttonStyles({ intent: "theme" })} mt-auto`}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={() => {
if (!isConnected) { open(); return; }
if (amount && parseFloat(amount) > 0) {
resetSupply();
executeApproveAndSupply(amount);
}
}}
>
{!mounted && t("common.loading")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && supplyStatus === 'idle' && !!amount && !isValidAmount(amount) && t("common.invalidAmount")}
{mounted && isConnected && supplyStatus === 'idle' && (!amount || isValidAmount(amount)) && t("supply.supply")}
{mounted && supplyStatus === 'approving' && t("common.approving")}
{mounted && supplyStatus === 'approved' && t("supply.approvedSupplying")}
{mounted && supplyStatus === 'supplying' && t("supply.supplying")}
{mounted && supplyStatus === 'success' && t("common.success")}
{mounted && supplyStatus === 'error' && t("common.failed")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,311 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
import { buttonStyles } from "@/lib/buttonStyles";
import { useUSDCBalance } from '@/hooks/useBalance';
import { useLendingWithdraw } from '@/hooks/useLendingWithdraw';
import { useSuppliedBalance } from '@/hooks/useLendingSupply';
import { getTxUrl } from '@/lib/contracts';
import { useTokenDecimalsFromAddress } from '@/hooks/useTokenDecimals';
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
import { useHealthFactor, useSupplyAPY, useBorrowBalance } from '@/hooks/useHealthFactor';
import { toast } from 'sonner';
import { useWalletStatus } from '@/hooks/useWalletStatus';
export default function WithdrawPanel() {
const { t } = useApp();
const [amount, setAmount] = useState("");
// 使用统一的钱包状态 Hook
const { isConnected, mounted } = useWalletStatus();
const { formattedBalance: usdcBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useUSDCBalance();
const { formattedBalance: suppliedBalance, refetch: refetchSupplied } = useSuppliedBalance();
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawLoading,
withdrawHash,
executeWithdraw,
reset: resetWithdraw,
} = useLendingWithdraw();
// 健康因子和 APY
const {
formattedHealthFactor,
status: healthStatus,
utilization,
isLoading: isHealthLoading
} = useHealthFactor();
const { apy } = useSupplyAPY();
const { formattedBalance: borrowBalance } = useBorrowBalance();
const hasShownBorrowWarning = useRef(false);
// 从产品 API 获取 USDC token 信息,优先从合约地址读取精度
const usdcToken = useTokenBySymbol('USDC');
const usdcInputDecimals = useTokenDecimalsFromAddress(usdcToken?.contractAddress, usdcToken?.decimals ?? 18);
const usdcDisplayDecimals = Math.min(usdcInputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > usdcDisplayDecimals) return;
if (parseFloat(value) > parseFloat(suppliedBalance)) { setAmount(truncateDecimals(suppliedBalance, usdcDisplayDecimals)); return; }
setAmount(value);
};
// 有借款时,挂载后弹出一次提示
useEffect(() => {
if (!mounted || hasShownBorrowWarning.current) return;
if (parseFloat(borrowBalance) > 0) {
toast.warning(t("supply.toast.borrowWarning"), {
description: t("supply.toast.borrowWarningDesc"),
duration: 5000,
});
hasShownBorrowWarning.current = true;
}
}, [mounted, borrowBalance]);
const WITHDRAW_TOAST_ID = 'lending-withdraw-tx';
// Withdraw 交易提交
useEffect(() => {
if (withdrawHash && withdrawStatus === 'withdrawing') {
toast.loading(t("supply.toast.withdrawSubmitted"), {
id: WITHDRAW_TOAST_ID,
description: t("supply.toast.processingWithdrawal"),
action: {
label: t("supply.toast.viewTx"),
onClick: () => window.open(getTxUrl(withdrawHash), '_blank'),
},
});
}
}, [withdrawHash, withdrawStatus]);
// 成功后刷新余额
useEffect(() => {
if (withdrawStatus === 'success') {
toast.success(t("supply.toast.withdrawnSuccess"), {
id: WITHDRAW_TOAST_ID,
description: t("supply.toast.withdrawnSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchSupplied();
const timer = setTimeout(() => { resetWithdraw(); setAmount(''); }, 3000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withdrawStatus]);
// 错误提示
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.dismiss(WITHDRAW_TOAST_ID);
} else {
toast.error(t("supply.toast.withdrawFailed"), {
id: WITHDRAW_TOAST_ID,
duration: 5000,
});
}
}
}, [withdrawError]);
return (
<div className="p-6 flex flex-col gap-6 flex-1">
{/* Token Balance & Supplied */}
<div className="flex flex-col gap-4">
{/* Token Balance */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.tokenBalance")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
USDC
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : isBalanceLoading ? '...' : parseFloat(truncateDecimals(usdcBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
{/* Supplied */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-3 flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.supplied")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[150%]">
{!mounted ? '0' : parseFloat(truncateDecimals(suppliedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
USDC
</span>
</div>
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
${!mounted ? '0' : parseFloat(truncateDecimals(suppliedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })}
</span>
</div>
</div>
{/* Withdraw Section */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-4">
{/* Withdraw Label and Available */}
<div className="flex items-center gap-2">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("supply.withdraw")}
</span>
<div className="flex items-center gap-2 ml-auto">
<Image src="/icons/ui/wallet-icon.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{!mounted ? '0 USDC' : `${parseFloat(truncateDecimals(suppliedBalance, usdcDisplayDecimals)).toLocaleString('en-US', { maximumFractionDigits: usdcDisplayDecimals })} USDC`}
</span>
</div>
<Button
size="sm"
className={buttonStyles({ intent: "max" })}
onPress={() => setAmount(truncateDecimals(suppliedBalance, usdcDisplayDecimals))}
>
{t("supply.max")}
</Button>
</div>
{/* Input Row */}
<div className="flex items-center justify-between h-12">
{/* USDC Token Button */}
<div className="bg-bg-surface dark:bg-gray-800 rounded-full border border-border-normal dark:border-gray-600 p-2 flex items-center gap-2 h-12">
<Image
src="/assets/tokens/usd-coin-usdc-logo-10.svg"
alt="USDC"
width={32}
height={32}
/>
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
USDC
</span>
</div>
{/* Amount Input */}
<div className="flex flex-col items-end">
<input
type="text" inputMode="decimal"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder="0"
className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em] text-right bg-transparent outline-none w-24"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{amount ? `$${amount}` : "--"}
</span>
</div>
</div>
</div>
{/* Health Factor */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("supply.healthFactor")}: {isHealthLoading ? '...' : formattedHealthFactor}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{utilization.toFixed(1)}% {t("supply.utilization")}
</span>
</div>
<span className={`text-caption-tiny font-bold leading-[150%] tracking-[0.01em] ${
healthStatus === 'safe' ? 'text-[#10b981] dark:text-green-400' :
healthStatus === 'warning' ? 'text-[#ffb933] dark:text-yellow-400' :
healthStatus === 'danger' ? 'text-[#ff6900] dark:text-orange-400' :
'text-[#ef4444] dark:text-red-400'
}`}>
{healthStatus === 'safe' ? t("supply.safe") :
healthStatus === 'warning' ? t("supply.warning") :
healthStatus === 'danger' ? t("supply.danger") :
t("supply.critical")}
</span>
</div>
{/* Progress Bar */}
<div className="relative w-full h-2.5 rounded-full overflow-hidden">
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
}}
/>
<div
className="absolute left-0 top-0 h-full rounded-full transition-all duration-500 ease-out"
style={{
background: 'linear-gradient(90deg, #10b981 0%, #3b82f6 20%, #8b5cf6 40%, #ec4899 60%, #f59e0b 80%, #ef4444 100%)',
width: `${Math.min(utilization, 100)}%`,
boxShadow: utilization > 0 ? '0 0 8px rgba(59, 130, 246, 0.5)' : 'none',
}}
/>
</div>
</div>
{/* Current APY */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl p-4 flex flex-col gap-2">
<span className="text-body-small font-medium text-text-secondary dark:text-gray-300 leading-[150%]">
{t("supply.currentReturns")}
</span>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.supplyApy")}
</span>
<span className="text-body-small font-bold text-[#ff6900] dark:text-orange-400 leading-[150%]">
{apy > 0 ? `${apy.toFixed(2)}%` : '--'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("supply.yearlyEarnings")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 leading-[150%]">
{apy > 0 && parseFloat(suppliedBalance) > 0
? `~ $${(parseFloat(suppliedBalance) * apy / 100).toFixed(2)}`
: '~ $0.00'}
</span>
</div>
</div>
{/* Withdraw Button */}
<Button
isDisabled={
!mounted ||
!isConnected ||
!isValidAmount(amount) ||
parseFloat(amount) > parseFloat(suppliedBalance) ||
isWithdrawLoading
}
className={`${buttonStyles({ intent: "theme" })} mt-auto`}
endContent={<Image src="/icons/actions/arrow-right-icon.svg" alt="" width={20} height={20} />}
onPress={() => {
if (amount && parseFloat(amount) > 0) {
resetWithdraw();
executeWithdraw(amount);
}
}}
>
{!mounted && t("common.loading")}
{mounted && !isConnected && t("common.connectWallet")}
{mounted && isConnected && !!amount && !isValidAmount(amount) && t("common.invalidAmount")}
{mounted && isConnected && isValidAmount(amount) && parseFloat(amount) > parseFloat(suppliedBalance) && t("supply.insufficientBalance")}
{mounted && isConnected && withdrawStatus === 'idle' && (!amount || isValidAmount(amount)) && parseFloat(amount) <= parseFloat(suppliedBalance) && t("supply.withdraw")}
{mounted && withdrawStatus === 'withdrawing' && t("supply.withdrawing")}
{mounted && withdrawStatus === 'success' && t("common.success")}
{mounted && withdrawStatus === 'error' && t("common.failed")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { fetchActivities, type ActivitiesData } from "@/lib/api/points";
import { useApp } from "@/contexts/AppContext";
type FilterTab = "all" | "referrals" | "deposits";
export default function ActivityHistory() {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<FilterTab>("all");
const [currentPage, setCurrentPage] = useState(1);
const [data, setData] = useState<ActivitiesData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadActivities();
}, [activeTab, currentPage]);
async function loadActivities() {
setLoading(true);
const result = await fetchActivities(activeTab, currentPage, 5);
setData(result);
setLoading(false);
}
const totalPages = data?.pagination.totalPage || 1;
return (
<div className="w-full bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6 animate-fade-in">
{/* Top Section - Title and Filter Tabs */}
<div className="flex items-center justify-between">
<h3 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{t("points.activityHistory")}
</h3>
{/* Filter Tabs */}
<div className="bg-[#f9fafb] dark:bg-gray-700 rounded-xl p-1 flex items-center gap-0 h-9">
<button
onClick={() => setActiveTab("all")}
className={`px-4 h-full rounded-lg text-body-small font-bold transition-all min-w-[60px] ${
activeTab === "all"
? "bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("points.all")}
</button>
<button
onClick={() => setActiveTab("referrals")}
className={`px-4 h-full rounded-lg text-body-small font-bold transition-all min-w-[90px] ${
activeTab === "referrals"
? "bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("points.referrals")}
</button>
<button
onClick={() => setActiveTab("deposits")}
className={`px-4 h-full rounded-lg text-body-small font-bold transition-all min-w-[85px] ${
activeTab === "deposits"
? "bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("points.deposits")}
</button>
</div>
</div>
{/* Table Card */}
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 overflow-hidden">
{/* Table Header Section */}
<div className="bg-bg-surface dark:bg-gray-800 border-b border-border-gray dark:border-gray-700 p-6 flex items-center justify-between">
<div className="flex flex-col gap-1">
<h4 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{t("points.activityHistory")}
</h4>
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.trackActivities")}
</p>
</div>
<div className="flex items-center gap-2">
<Image src="/icons/actions/icon-refresh.svg" alt="Refresh" width={16} height={16} />
<span className="text-caption-tiny font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.refreshLastUpdated").replace("{time}", "2")}
</span>
</div>
</div>
{/* Table */}
<div className="overflow-auto">
{/* Table Header */}
<div className="bg-bg-subtle dark:bg-gray-700/30 border-b border-border-gray dark:border-gray-700 flex">
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.user")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.friends")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.code")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.points")}
</span>
</div>
</div>
{/* Table Body */}
{loading ? (
<div className="p-6 text-center">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full animate-pulse" />
</div>
) : data && data.activities && data.activities.length > 0 ? (
data.activities.map((row, index) => (
<div
key={index}
className={`flex ${
index !== data.activities.length - 1 ? "border-b border-border-gray dark:border-gray-700" : ""
}`}
>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white font-inter">
{row.userAddress}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white font-inter">
{row.friendAddress || "-"}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white font-inter">
{row.inviteCode || "-"}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold leading-[150%]" style={{ color: "#10b981" }}>
+{row.points}
</span>
</div>
</div>
))
) : (
<div className="p-6 text-center">
<span className="text-text-tertiary dark:text-gray-400">{t("points.noActivitiesFound")}</span>
</div>
)}
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4">
{/* Previous Button */}
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="w-10 h-10 flex items-center justify-center disabled:opacity-50"
>
<Image src="/icons/ui/icon-chevron-left.svg" alt="Previous" width={10} height={10} />
</button>
{/* Page Numbers */}
<div className="flex items-center gap-3">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`px-[10px] py-[3px] rounded-lg text-sm leading-[22px] transition-all ${
currentPage === page
? "bg-bg-subtle dark:bg-gray-700 text-text-primary dark:text-white"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{page}
</button>
))}
</div>
{/* Next Button */}
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="w-10 h-10 flex items-center justify-center disabled:opacity-50"
>
<Image src="/icons/ui/icon-chevron-right.svg" alt="Next" width={10} height={10} />
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
interface BindInviteCardProps {
placeholder?: string;
onApply?: (code: string) => void;
}
export default function BindInviteCard({
placeholder,
onApply,
}: BindInviteCardProps) {
const { t } = useApp();
const [code, setCode] = useState("");
const handleApply = () => {
if (code.trim()) {
onApply?.(code.trim());
}
};
return (
<div className="flex-[32] bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-3 h-6">
<Image src="/components/card/icon2.svg" alt="" width={24} height={24} />
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
{t("points.bindInvite")}
</span>
</div>
{/* Description */}
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.bindInviteDescription")}
</p>
{/* Input and Button */}
<div className="flex flex-col gap-4">
{/* Input Field */}
<div className="flex items-center gap-2">
<div className="flex-1 bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 px-4 py-3 h-[46px] flex items-center">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder={placeholder || t("points.enterCode")}
className="w-full bg-transparent text-body-default font-bold text-text-primary dark:text-white leading-[150%] font-inter outline-none placeholder:text-[#d1d5db] dark:placeholder:text-gray-500"
/>
</div>
</div>
{/* Apply Button */}
<Button
onClick={handleApply}
disabled={!code.trim()}
className="bg-text-primary dark:bg-white rounded-xl h-11 text-body-small font-bold text-white dark:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("points.apply")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
interface DepositCardProps {
logo?: string;
title: string;
subtitle: string;
badge: string;
lockPeriod: string;
progressPercent: number;
pointsBoost: string;
onDeposit?: () => void;
}
export default function DepositCard({
logo,
title,
subtitle,
badge,
lockPeriod,
progressPercent,
pointsBoost,
onDeposit,
}: DepositCardProps) {
const { t } = useApp();
return (
<div className="w-full 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">
{/* Top Section */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
{/* Logo and Title */}
<div className="flex items-center gap-3 md:gap-6">
{logo ? (
<Image src={logo} alt={title} width={40} height={40} className="rounded-full flex-shrink-0" />
) : (
<div
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
style={{ background: "linear-gradient(135deg, rgba(0, 187, 167, 1) 0%, rgba(0, 122, 85, 1) 100%)" }}
>
<span className="text-[10px] font-bold text-white leading-[125%] tracking-[-0.11px]">
{t("points.logo")}
</span>
</div>
)}
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{title}
</h3>
<div
className="rounded-full px-3 py-1 flex items-center justify-center flex-shrink-0"
style={{ background: "#ff6900", boxShadow: "0px 4px 6px -4px rgba(255, 105, 0, 0.2), 0px 10px 15px -3px rgba(255, 105, 0, 0.2)" }}
>
<span className="text-[10px] font-bold text-white leading-[150%] tracking-[0.01em]">
{badge}
</span>
</div>
</div>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[133%]">
{subtitle}
</span>
</div>
</div>
{/* Lock Period and Button */}
<div className="flex items-center justify-between md:justify-end gap-4 md:gap-12">
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.lockPeriod")}
</span>
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
{lockPeriod}
</span>
</div>
<Button
onClick={onDeposit}
className="bg-text-primary dark:bg-white rounded-xl h-11 px-4 text-body-small font-bold text-white dark:text-gray-900 flex items-center gap-1 w-[163px] flex-shrink-0"
>
{t("points.startLoop")}
<Image src="/icons/actions/icon-arrow-right.svg" alt="" width={16} height={16} />
</Button>
</div>
</div>
{/* Bottom Section - Progress Bar */}
<div className="flex items-center justify-between border-t border-[#f1f5f9] dark:border-gray-600 pt-6">
{/* Progress Bar */}
<div className="relative flex-1 h-2 bg-[#f1f5f9] dark:bg-gray-700 rounded-full overflow-hidden max-w-[447px]">
<div
className="absolute left-0 top-0 h-full rounded-full"
style={{
width: `${progressPercent}%`,
background: "#00bc7d",
boxShadow: "0px 0px 15px 0px rgba(16, 185, 129, 0.4)"
}}
/>
</div>
{/* Points Badge */}
<span
className="text-[10px] font-black leading-[150%] tracking-[1.12px] ml-4"
style={{ color: "#00bc7d" }}
>
{pointsBoost}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { Button } from "@heroui/react";
import BorderedButton from "@/components/common/BorderedButton";
import { useApp } from "@/contexts/AppContext";
interface EarnOpportunityCardProps {
pointsLabel: string;
title: string;
subtitle: string;
metricLabel: string;
metricValue: string;
buttonText: string;
onButtonClick?: () => void;
}
export default function EarnOpportunityCard({
pointsLabel,
title,
subtitle,
metricLabel,
metricValue,
buttonText,
onButtonClick,
}: EarnOpportunityCardProps) {
const { t } = useApp();
return (
<div className="flex-1 bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6">
{/* Top Section - Logo and Points Badge */}
<div className="flex items-start justify-between">
{/* Logo */}
<div
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
style={{
background: "linear-gradient(135deg, rgba(0, 187, 167, 1) 0%, rgba(0, 122, 85, 1) 100%)"
}}
>
<span className="text-[10px] font-bold text-white leading-[125%] tracking-[-0.11px]">
{t("points.logo")}
</span>
</div>
{/* Points Badge */}
<div
className="rounded-full px-3 py-1 flex items-center justify-center"
style={{
background: "#fff5ef",
border: "1px solid #ffc9ad"
}}
>
<span className="text-[10px] font-bold leading-[150%] tracking-[0.01em]" style={{ color: "#ff6900" }}>
{pointsLabel}
</span>
</div>
</div>
{/* Middle Section - Title and Subtitle */}
<div className="flex flex-col gap-1">
<h3 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{title}
</h3>
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{subtitle}
</p>
</div>
{/* Bottom Section - Metric and Button */}
<div className="flex items-center gap-6">
{/* Metric */}
<div className="flex-1 flex flex-col gap-1">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{metricLabel}
</span>
<span className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{metricValue}
</span>
</div>
{/* Button */}
<BorderedButton
size="lg"
onClick={onButtonClick}
className="bg-[#f3f4f6] dark:bg-gray-700 text-text-primary dark:text-white"
>
{buttonText}
</BorderedButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { useAccount } from "wagmi";
import EarnOpportunityCard from "./EarnOpportunityCard";
import ActivityHistory from "./ActivityHistory";
import TopPerformers from "./TopPerformers";
import ReferralCodeCard from "./ReferralCodeCard";
import BindInviteCard from "./BindInviteCard";
import TeamTVLCard from "./TeamTVLCard";
import DepositCard from "./DepositCard";
import { fetchTeamTVL, bindInviteCode, registerWallet, type TeamTVLData } from "@/lib/api/points";
import { useApp } from "@/contexts/AppContext";
export default function PointsCards() {
const { t } = useApp();
const { address } = useAccount();
const [mounted, setMounted] = useState(false);
const [teamTVL, setTeamTVL] = useState<TeamTVLData | null>(null);
const [loading, setLoading] = useState(true);
const [inviteCode, setInviteCode] = useState('');
const [inviteUsedCount, setInviteUsedCount] = useState(0);
const [inviteLoading, setInviteLoading] = useState(false);
useEffect(() => { setMounted(true); }, []);
// Register wallet and load user data when address connects
useEffect(() => {
if (address) {
registerAndLoad(address);
}
}, [address]);
async function registerAndLoad(walletAddress: string) {
setInviteLoading(true);
// Register wallet (creates user if not exists), get invite code
const userData = await registerWallet(walletAddress);
if (userData) {
setInviteCode(userData.inviteCode);
setInviteUsedCount(userData.usedCount);
}
setInviteLoading(false);
// Load team TVL for this wallet
setLoading(true);
const teamData = await fetchTeamTVL(walletAddress);
setTeamTVL(teamData);
setLoading(false);
}
const handleCopy = () => {
if (inviteCode) {
navigator.clipboard.writeText(inviteCode);
toast.success(t("points.copiedToClipboard"));
}
};
const handleShare = async () => {
if (!inviteCode) return;
const shareUrl = `${window.location.origin}?ref=${inviteCode}`;
if (navigator.share) {
try {
await navigator.share({ url: shareUrl });
} catch {
// user cancelled, do nothing
}
} else {
navigator.clipboard.writeText(shareUrl);
toast.success(t("points.shareLinkCopied"));
}
};
const handleApply = async (code: string) => {
if (!code) {
toast.error(t("points.pleaseEnterInviteCode"));
return;
}
// TODO: Get signature from wallet
const signature = "0x..."; // Placeholder
const result = await bindInviteCode(code, signature, address);
if (result.success) {
toast.success(result.message || t("points.inviteCodeBoundSuccess"));
if (address) registerAndLoad(address); // Reload data
} else {
toast.error(result.error || t("points.failedToBindInviteCode"));
}
};
return (
<div className="flex flex-col gap-6">
{/* First Row - Cards 1, 2, 3 */}
<div className="flex flex-col md:flex-row gap-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<ReferralCodeCard
code={inviteCode || t("points.loading")}
usedCount={inviteUsedCount}
loading={!mounted || inviteLoading || !address}
onCopy={handleCopy}
onShare={handleShare}
/>
<BindInviteCard
onApply={handleApply}
/>
<TeamTVLCard
currentTVL={teamTVL?.currentTVL || "$0"}
targetTVL={teamTVL?.targetTVL || "$10M"}
progressPercent={teamTVL?.progressPercent || 0}
members={teamTVL?.totalMembers || 0}
roles={teamTVL?.roles || []}
loading={loading}
/>
</div>
{/* Second Row - Card 4 */}
<div className="animate-fade-in" style={{ animationDelay: '0.2s' }}>
<DepositCard
title="Deposit USDC to ALP"
subtitle="Native Staking"
badge="UP TO 3X"
lockPeriod="30 DAYS"
progressPercent={65}
pointsBoost="+10% POINTS"
onDeposit={() => {}}
/>
</div>
{/* Third Row - Earn Opportunities */}
<div className="flex flex-col md:flex-row gap-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
<EarnOpportunityCard
pointsLabel="7X Points"
title="Pendle YT"
subtitle="Yield Trading optimization"
metricLabel="EST. APY"
metricValue="50%-300%"
buttonText="ZAP IN"
onButtonClick={() => {}}
/>
<EarnOpportunityCard
pointsLabel="4X Points"
title="Curve LP"
subtitle="Liquidity Pool provision"
metricLabel="CURRENT APY"
metricValue="15%-35%"
buttonText="Add Liquidity"
onButtonClick={() => {}}
/>
<EarnOpportunityCard
pointsLabel="2X-8X Points"
title="Morpho"
subtitle="Lending Loop strategy"
metricLabel="MAX LTV"
metricValue="85%"
buttonText="Start LOOP"
onButtonClick={() => {}}
/>
</div>
{/* Fourth Row - Activity History and Top Performers */}
<div className="flex flex-col md:flex-row gap-6 animate-fade-in" style={{ animationDelay: '0.4s' }}>
<div className="flex-[2]">
<ActivityHistory />
</div>
<div className="flex-1">
<TopPerformers />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import VipCard from "./VipCard";
import { useApp } from "@/contexts/AppContext";
import { fetchDashboard, type DashboardData } from "@/lib/api/points";
export default function PointsDashboard() {
const { t } = useApp();
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadDashboard() {
setLoading(true);
const data = await fetchDashboard();
setDashboard(data);
setLoading(false);
}
loadDashboard();
}, []);
if (loading) {
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 animate-pulse">
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl" />
</div>
);
}
if (!dashboard) {
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 animate-fade-in">
<p className="text-text-tertiary dark:text-gray-400">{t("points.failedToLoadDashboard")}</p>
</div>
);
}
return (
<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 animate-fade-in">
{/* Header Row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-[#f9fafb] dark:bg-gray-700 border border-border-normal dark:border-gray-600 rounded-full px-3 py-1 flex items-center justify-center">
<span className="text-caption-tiny font-bold text-text-primary dark:text-white leading-[150%] tracking-[0.01em]">
{dashboard.season.seasonName}
</span>
</div>
{dashboard.season.isLive && (
<div className="bg-[#e1f8ec] dark:bg-green-900/30 border border-[#b8ecd2] dark:border-green-700 rounded-full px-3 py-1 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-[#10b981] opacity-55" />
<span className="text-caption-tiny font-bold text-[#10b981] dark:text-green-400 leading-[150%] tracking-[0.01em]">
{t("points.live")}
</span>
</div>
)}
</div>
<div className="rounded-[14px] px-4 py-2 flex items-center gap-2 h-[34.78px] shadow-lg bg-gradient-to-r from-[#fee685] to-[#fdc700] rotate-1">
<Image src="/components/points/icon-star.svg" alt="" width={17} height={17} />
<span className="text-caption-tiny font-bold text-text-primary leading-[150%] tracking-[0.01em]">
{t("points.xPoints")}
</span>
</div>
</div>
{/* ── 移动端布局 ── */}
<div className="md:hidden flex flex-col gap-4">
{/* Title */}
<div className="flex flex-col gap-1">
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{t("points.pointsDashboard")}
</h2>
<p className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
<span>{t("points.unlockUpTo")} </span>
<span className="font-bold text-[#fdc700]">{t("points.xPoints")}</span>
<span> {t("points.withEcosystemMultipliers")}</span>
</p>
</div>
{/* Stats Row - 3 items in one line */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl border border-white/5 dark:border-gray-600 px-4 py-3 flex items-center justify-between gap-2">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.yourTotalPoints")}
</span>
<span className="text-[22px] font-extrabold leading-[110%] tracking-[-0.01em] bg-gradient-to-b from-[#111827] to-[#4b5563] bg-clip-text text-transparent dark:from-white dark:to-gray-400">
{dashboard.totalPoints.toLocaleString()}
</span>
</div>
<div className="w-px h-8 bg-border-normal dark:bg-gray-600 flex-shrink-0" />
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.globalRank")}
</span>
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[130%]">
#{dashboard.globalRank.toLocaleString()}
</span>
<span className="text-[10px] font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
Top {dashboard.topPercentage}
</span>
</div>
<div className="w-px h-8 bg-border-normal dark:bg-gray-600 flex-shrink-0" />
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.endsIn")}
</span>
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[130%]">
{dashboard.season.daysRemaining}d {dashboard.season.hoursRemaining}h
</span>
<span className="text-[10px] font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{new Date(dashboard.season.endTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
</div>
{/* VipCard - full width, own row */}
<VipCard
memberTier={dashboard.memberTier}
level={dashboard.vipLevel}
currentPoints={dashboard.totalPoints}
totalPoints={dashboard.totalPoints + dashboard.pointsToNextTier}
nextTier={dashboard.nextTier}
pointsToNextTier={dashboard.pointsToNextTier}
/>
</div>
{/* ── 桌面端布局 ── */}
<div className="hidden md:flex gap-6">
{/* Left Section - Points Info */}
<div className="flex-1 flex flex-col gap-6">
{/* Title */}
<div className="flex flex-col gap-1">
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{t("points.pointsDashboard")}
</h2>
<p className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
<span>{t("points.unlockUpTo")} </span>
<span className="font-bold text-[#fdc700]">{t("points.xPoints")}</span>
<span> {t("points.withEcosystemMultipliers")}</span>
</p>
</div>
{/* Stats Grid */}
<div className="flex items-center justify-between gap-6">
<div className="flex flex-col gap-1">
<span className="text-body-small font-bold text-text-secondary dark:text-gray-300 leading-[150%]">
{t("points.yourTotalPoints")}
</span>
<span className="text-[48px] font-extrabold leading-[110%] tracking-[-0.01em] font-inter bg-gradient-to-b from-[#111827] to-[#4b5563] bg-clip-text text-transparent dark:from-white dark:to-gray-400">
{dashboard.totalPoints.toLocaleString()}
</span>
</div>
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl border border-white/5 dark:border-gray-600 px-6 h-28 flex items-center gap-12">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.globalRank")}
</span>
<span className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
#{dashboard.globalRank.toLocaleString()}
</span>
<span className="text-[10px] font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
Top {dashboard.topPercentage}
</span>
</div>
<div className="w-px h-8 bg-border-normal dark:bg-gray-600" />
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.endsIn")}
</span>
<span className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{dashboard.season.daysRemaining}d {dashboard.season.hoursRemaining}h
</span>
<span className="text-[10px] font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{new Date(dashboard.season.endTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
</div>
</div>
</div>
{/* Right Section - VIP Card */}
<VipCard
memberTier={dashboard.memberTier}
level={dashboard.vipLevel}
currentPoints={dashboard.totalPoints}
totalPoints={dashboard.totalPoints + dashboard.pointsToNextTier}
nextTier={dashboard.nextTier}
pointsToNextTier={dashboard.pointsToNextTier}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { Copy } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@heroui/react";
import Image from "next/image";
import BorderedButton from "@/components/common/BorderedButton";
import { useApp } from "@/contexts/AppContext";
interface ReferralCodeCardProps {
code: string;
usedCount?: number;
loading?: boolean;
onCopy?: () => void;
onShare?: () => void;
}
export default function ReferralCodeCard({
code = "PR0T0-8821",
usedCount = 0,
loading = false,
onCopy,
onShare,
}: ReferralCodeCardProps) {
const { t } = useApp();
const [copied, setCopied] = useState(false);
const handleCopyClick = () => {
onCopy?.();
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="flex-[32] bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-3 h-6">
<Image src="/components/card/icon0.svg" alt="" width={24} height={24} />
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
{t("points.referralCode")}
</span>
</div>
{/* Description */}
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.shareYourCodeDescription").replace("{count}", usedCount.toString())}
</p>
{/* Code Display and Buttons */}
<div className="flex flex-col gap-4">
{/* Code Display Row */}
<div className="flex items-center gap-2">
{/* Code Container */}
<div className="flex-1 bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 px-4 py-3 h-[46px] flex items-center">
{loading ? (
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-24 animate-pulse" />
) : (
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%] font-inter">
{code === "Loading..." ? t("points.loading") : code}
</span>
)}
</div>
{/* Copy Button */}
<Button
isIconOnly
onClick={handleCopyClick}
className="rounded-xl w-[46px] h-[46px] min-w-[46px] bg-text-primary dark:bg-white"
>
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.svg
key="check"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="#22c55e" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={{ duration: 0.2 }}
>
<motion.path
d="M4 12 L10 18 L20 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</motion.svg>
) : (
<motion.span
key="copy"
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={{ duration: 0.15 }}
>
<Copy size={20} className="text-white dark:text-black" />
</motion.span>
)}
</AnimatePresence>
</Button>
</div>
{/* Share Button */}
<BorderedButton
size="lg"
fullWidth
onClick={onShare}
isTheme
>
{t("points.share")}
</BorderedButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
interface RoleIndicator {
icon: string;
label: string;
current: number;
target: number;
}
interface TeamTVLCardProps {
currentTVL: string;
targetTVL?: string;
progressPercent: number;
members: number;
roles: RoleIndicator[];
loading?: boolean;
}
export default function TeamTVLCard({
currentTVL = "$8.5M",
targetTVL = "$10M",
progressPercent = 85,
members = 12,
roles = [],
loading = false,
}: TeamTVLCardProps) {
const { t } = useApp();
return (
<div className="flex-[32] bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6 relative">
{/* Header */}
<div className="flex items-center gap-3">
<Image src="/components/card/icon3.svg" alt="" width={24} height={24} />
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
{t("points.teamTVLReward")}
</span>
</div>
{/* Description */}
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.buildYourTeam")}
</p>
{/* Progress Section */}
<div className="flex flex-col gap-4">
{/* TVL Info */}
<div className="flex flex-col gap-1">
<div className="flex items-start justify-between">
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.currentTeamTVL")}
</span>
{loading ? (
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-20 animate-pulse" />
) : (
<span className="text-caption-tiny font-bold text-text-primary dark:text-white leading-[150%] tracking-[0.01em]">
{currentTVL} / {targetTVL}
</span>
)}
</div>
{/* Progress Bar */}
<div className="relative w-full h-2 bg-[#f3f4f6] dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="absolute left-0 top-0 h-full rounded-full"
style={{
width: `${progressPercent}%`,
background: "linear-gradient(90deg, rgba(20, 71, 230, 1) 0%, rgba(3, 43, 189, 1) 100%)"
}}
/>
</div>
</div>
{/* Role Indicators */}
<div className="flex items-center gap-2">
{roles.map((role, index) => (
<div
key={index}
className="flex-1 bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 px-4 py-2 flex flex-col items-center justify-center gap-0"
>
<div className="flex items-center justify-center h-4">
<Image src={role.icon} alt={role.label} width={16} height={16} />
</div>
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{role.label}
</span>
<span className="text-caption-tiny font-bold text-text-primary dark:text-white leading-[150%] tracking-[0.01em]">
{role.current}/{role.target}
</span>
</div>
))}
</div>
</div>
{/* Members Badge */}
<div className="absolute right-6 top-8 bg-[#e1f8ec] dark:bg-green-900/30 border border-[#b8ecd2] dark:border-green-700 rounded-full px-3 py-1 flex items-center">
<span className="text-[10px] font-bold text-[#10b981] dark:text-green-400 leading-[100%] tracking-[0.01em]">
{t("points.membersCount").replace("{count}", members.toString())}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { fetchLeaderboard, formatPoints, type LeaderboardData } from "@/lib/api/points";
import { useApp } from "@/contexts/AppContext";
export default function TopPerformers() {
const { t } = useApp();
const [leaderboard, setLeaderboard] = useState<LeaderboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadLeaderboard() {
setLoading(true);
const data = await fetchLeaderboard(5);
setLeaderboard(data);
setLoading(false);
}
loadLeaderboard();
}, []);
const getRowStyle = (rank: number) => {
switch (rank) {
case 1:
return {
bg: "#fff5ef",
borderColor: "#ff6900",
rankColor: "#ff6900",
textColor: "#111827",
pointsSize: "text-body-large",
};
case 2:
return {
bg: "#fffbf5",
borderColor: "#ffb933",
rankColor: "#ffb933",
textColor: "#111827",
pointsSize: "text-body-large",
};
case 3:
return {
bg: "#f3f4f6",
borderColor: "#6b7280",
rankColor: "#6b7280",
textColor: "#111827",
pointsSize: "text-body-large",
};
default:
return {
bg: "transparent",
borderColor: "transparent",
rankColor: "#d1d5db",
textColor: "#9ca1af",
pointsSize: "text-body-small",
};
}
};
if (loading) {
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden h-full animate-pulse">
<div className="p-6 border-b border-[#f1f5f9] dark:border-gray-700">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32" />
</div>
<div className="flex-1 flex flex-col">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-[72px] border-l-4 border-transparent px-6 flex items-center">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
</div>
))}
</div>
</div>
);
}
if (!leaderboard) {
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8">
<p className="text-text-tertiary dark:text-gray-400">{t("points.failedToLoadLeaderboard")}</p>
</div>
);
}
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden h-full animate-fade-in">
{/* Header */}
<div className="border-b border-[#f1f5f9] dark:border-gray-700 px-6 pt-8 pb-8">
<h3 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{t("points.topPerformers")}
</h3>
</div>
{/* Performers List */}
<div className="flex-1 flex flex-col">
{leaderboard.topUsers.map((performer) => {
const style = getRowStyle(performer.rank);
const isTopThree = performer.rank <= 3;
const height = performer.rank <= 3 ? "h-[72px]" : "h-[68px]";
return (
<div
key={performer.rank}
className={`flex items-center justify-between px-6 ${height} border-l-4`}
style={{
backgroundColor: style.bg,
borderLeftColor: style.borderColor,
}}
>
{/* Left - Rank and Address */}
<div className="flex items-center gap-5">
{/* Rank Number */}
<span
className={`font-black italic leading-[133%] ${
isTopThree ? "text-2xl tracking-[0.07px]" : "text-lg tracking-[-0.44px]"
}`}
style={{ color: style.rankColor }}
>
{performer.rank}
</span>
{/* Address */}
<span
className="text-body-small font-bold font-inter leading-[150%]"
style={{ color: style.textColor }}
>
{performer.address}
</span>
</div>
{/* Right - Points */}
<span
className={`${style.pointsSize} font-bold leading-[150%]`}
style={{
color: isTopThree ? "#111827" : "#4b5563",
}}
>
{formatPoints(performer.points)}
</span>
</div>
);
})}
</div>
{/* Footer - My Rank */}
<div className="border-t border-border-gray dark:border-gray-700 px-6 py-6 flex items-center justify-between">
<div className="flex items-center gap-1">
<Image src="/icons/ui/icon-chart.svg" alt="Chart" width={16} height={16} />
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.myRank")}
</span>
</div>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{leaderboard.myPoints.toLocaleString()}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
interface VipCardProps {
memberTier: string;
level: number;
currentPoints: number;
totalPoints: number;
nextTier: string;
pointsToNextTier: number;
}
export default function VipCard({
memberTier = "Silver Member",
level = 2,
currentPoints = 2450,
totalPoints = 3000,
nextTier = "Gold",
pointsToNextTier = 550,
}: VipCardProps) {
const { t } = useApp();
const progressPercent = (currentPoints / totalPoints) * 100;
const progressBarPercent = Math.min(progressPercent * 0.47, 38.4); // 根据原型调整比例
return (
<div className="w-full md:w-[340px] h-[198px] relative rounded-xl overflow-hidden" style={{ background: "linear-gradient(180deg, #484848 0%, #1e1e1e 100%)" }}>
{/* Top Left - Member Info */}
<div className="absolute left-6 top-[33px]">
<div className="flex flex-col gap-1">
<span className="text-body-default font-bold text-white leading-[150%]">
{memberTier}
</span>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("points.currentTier")}
</span>
</div>
</div>
{/* Top Right - VIP Badge */}
<div className="absolute right-6 top-[26px]">
<div className="relative w-[55px] h-[28px]">
{/* Badge Layers */}
<div className="absolute left-[0.45px] top-[6px]">
<Image src="/components/points/polygon-30.svg" alt="" width={24} height={15} />
</div>
<div className="absolute left-[28.55px] top-[6px]">
<Image src="/components/points/polygon-40.svg" alt="" width={24} height={15} />
</div>
<div className="absolute left-[4px] top-[3px]">
<Image src="/components/points/polygon-20.svg" alt="" width={21} height={25} />
</div>
<div className="absolute left-[4px] top-[3px]">
<Image src="/components/points/mask-group1.svg" alt="" width={21} height={25} />
</div>
{/* Decorative circles */}
<div className="absolute left-[12.84px] top-0 w-[3.32px] h-[3.32px] rounded-full" style={{ background: "linear-gradient(180deg, #ffcd1d 0%, #ff971d 100%)" }} />
<div className="absolute left-0 top-[5.14px] w-[2.72px] h-[2.72px] rounded-full" style={{ background: "linear-gradient(180deg, #ffcd1d 0%, #ff971d 100%)" }} />
<div className="absolute left-[26.2px] top-[5.14px] w-[2.72px] h-[2.72px] rounded-full" style={{ background: "linear-gradient(180deg, #ffcd1d 0%, #ff971d 100%)" }} />
{/* Level Badge */}
<div className="absolute left-[24px] top-[9.62px]">
<Image src="/components/points/rectangle-420.svg" alt="" width={31} height={12} />
<span className="absolute left-[9.24px] top-0 text-[8.5px] font-semibold leading-[120%] uppercase" style={{ color: "#a07400" }}>
LV{level}
</span>
</div>
</div>
</div>
{/* Bottom - Progress Section */}
<div className="absolute left-1/2 -translate-x-1/2 top-[107px] w-[280px] flex flex-col gap-1">
{/* Progress Label */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-regular leading-[150%] tracking-[0.01em]" style={{ color: "#d1d5db" }}>
{t("points.progress")}
</span>
<span className="text-caption-tiny font-bold text-white leading-[150%] tracking-[0.01em]">
{currentPoints} / {totalPoints}
</span>
</div>
{/* Progress Bar */}
<div className="relative w-full h-1 rounded-[5px]" style={{ background: "#343434" }}>
<div
className="absolute left-0 top-0 h-full rounded-[5px]"
style={{
width: `${progressBarPercent}%`,
background: "linear-gradient(90deg, rgba(255, 255, 255, 1) 95.15%, rgba(255, 255, 255, 0) 100%)"
}}
/>
<div
className="absolute w-[10px] h-[10px] rounded-full bg-white -top-[3px]"
style={{
left: `${Math.max(0, progressBarPercent - 3)}%`,
filter: "blur(3.42px)"
}}
/>
</div>
</div>
{/* Bottom - Next Tier */}
<div className="absolute left-6 top-[163px] flex items-center gap-1">
<span className="text-caption-tiny font-regular text-white leading-[150%] tracking-[0.01em]">
{t("points.pointsToNextTier")
.replace("{points}", pointsToNextTier.toString())
.split("{tier}")[0]}
<span className="font-bold">{nextTier}</span>
</span>
<Image src="/icons/actions/icon-arrow-gold.svg" alt="" width={16} height={16} />
</div>
</div>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useApp } from "@/contexts/AppContext";
import { fetchProductHistory, HistoryPoint } from "@/lib/api/fundmarket";
import * as echarts from "echarts";
interface APYHistoryCardProps {
productId: number;
}
export default function APYHistoryCard({ productId }: APYHistoryCardProps) {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<"apy" | "price">("apy");
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const { data: history = [] } = useQuery<HistoryPoint[]>({
queryKey: ["product-history", productId],
queryFn: () => fetchProductHistory(productId),
staleTime: 60 * 60 * 1000, // 1h
});
const colors = [
"#FBEADE", "#F5D4BE", "#EFBF9E", "#E9AA7E", "#E3955E",
"#DD804E", "#D76B3E", "#D1562E", "#C65122",
];
const isEmpty = history.length < 2;
const labels = isEmpty ? [] : history.map((p) => {
const d = new Date(p.time);
return `${d.getMonth() + 1}/${d.getDate()}`;
});
const apyData = isEmpty ? [] : history.map((p) => p.apy);
const priceData = isEmpty ? [] : history.map((p) => p.price);
const activeData = activeTab === "apy" ? apyData : priceData;
const highest = activeData.length > 0 ? Math.max(...activeData) : 0;
const lowest = activeData.length > 0 ? Math.min(...activeData) : 0;
const updateChart = () => {
if (!chartInstance.current) return;
// price tab 自适应 Y 轴,上下各留 20% paddingAPY bar 图保持从 0 开始
let yAxisMin: number | undefined;
let yAxisMax: number | undefined;
if (activeTab === "price" && priceData.length > 0) {
const yMin = Math.min(...priceData);
const yMax = Math.max(...priceData);
const range = yMax - yMin || yMax * 0.01 || 0.01;
yAxisMin = yMin - range * 0.2;
yAxisMax = yMax + range * 0.2;
}
const option: echarts.EChartsOption = {
grid: { left: 2, right: 2, top: 10, bottom: 0 },
tooltip: {
trigger: "axis",
show: true,
confine: true,
backgroundColor: "rgba(17, 24, 39, 0.9)",
borderColor: "#374151",
textStyle: { color: "#f9fafb", fontSize: 12, fontWeight: 500 },
formatter: (params: any) => {
const d = params[0];
const suffix = activeTab === "apy" ? "%" : " USDC";
return `<div style="padding:4px 8px">
<span style="color:#9ca3af;font-size:11px">${labels[d.dataIndex]}</span><br/>
<span style="color:#10b981;font-weight:600;font-size:14px">${Number(d.value).toFixed(activeTab === "apy" ? 2 : 4)}${suffix}</span>
</div>`;
},
},
xAxis: {
type: "category",
data: labels,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
show: labels.length > 0,
color: "#9ca3af",
fontSize: 10,
fontWeight: 500,
interval: Math.max(0, Math.floor(labels.length / 7) - 1),
},
},
yAxis: {
type: "value",
min: yAxisMin,
max: yAxisMax,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
series: [
activeTab === "apy"
? {
data: apyData.map((value, index) => ({
value,
itemStyle: {
color: colors[index % colors.length],
borderRadius: [2, 2, 0, 0],
},
})),
type: "bar",
barWidth: "60%",
barMaxWidth: 24,
barMinHeight: 2,
}
: {
data: priceData,
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
lineStyle: { color: "#10b981", width: 2 },
itemStyle: { color: "#10b981" },
areaStyle: {
color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: "rgba(16,185,129,0.3)" },
{ offset: 1, color: "rgba(16,185,129,0)" },
],
},
},
},
],
};
chartInstance.current.setOption(option, true);
};
useEffect(() => {
// Use requestAnimationFrame to ensure the container has been laid out
// before ECharts tries to measure its dimensions
const frame = requestAnimationFrame(() => {
if (!chartRef.current) return;
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
updateChart();
chartInstance.current?.resize();
});
const handleResize = () => chartInstance.current?.resize();
window.addEventListener("resize", handleResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", handleResize);
};
}, [activeTab, history]);
useEffect(() => {
return () => { chartInstance.current?.dispose(); };
}, []);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6 flex flex-col gap-6">
{/* Tabs */}
<div className="flex gap-6 border-b border-border-gray dark:border-gray-700">
<button
onClick={() => setActiveTab("apy")}
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
activeTab === "apy"
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("apy.apyHistory")}
</button>
<button
onClick={() => setActiveTab("price")}
className={`pb-3 px-1 text-body-small font-bold transition-colors ${
activeTab === "price"
? "text-text-primary dark:text-white border-b-2 border-text-primary dark:border-white -mb-[1px]"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("apy.priceHistory")}
</button>
</div>
{/* Chart Area */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{isEmpty ? t("apy.lastDays") : `${t("apy.lastDays")} (${history.length} snapshots)`}
</span>
</div>
{/* ECharts Chart */}
{isEmpty ? (
<div className="w-full h-[140px] flex items-center justify-center text-caption-tiny text-text-tertiary dark:text-gray-500">
{t("common.noData")}
</div>
) : (
<div
ref={chartRef}
className="w-full"
style={{
height: "140px",
background: activeTab === "price"
? "linear-gradient(0deg, rgba(16, 185, 129, 0.1) 0%, transparent 100%)"
: undefined,
}}
/>
)}
{/* Stats */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{t("apy.highest")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{isEmpty ? '--' : activeTab === "apy" ? `${highest.toFixed(2)}%` : `$${highest.toFixed(4)}`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400">
{t("apy.lowest")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">
{isEmpty ? '--' : activeTab === "apy" ? `${lowest.toFixed(2)}%` : `$${lowest.toFixed(4)}`}
</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,349 @@
"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>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useApp } from "@/contexts/AppContext";
import { ProductDetail } from "@/lib/api/fundmarket";
interface AssetDescriptionCardProps {
product: ProductDetail;
}
export default function AssetDescriptionCard({ product }: AssetDescriptionCardProps) {
const { t } = useApp();
return (
<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-4 md:min-h-[320px]">
<h3 className="text-body-large font-bold text-text-primary dark:text-white">
{t("description.title")}
</h3>
<div className="text-body-default font-regular text-text-primary dark:text-gray-300 leading-relaxed whitespace-pre-line overflow-y-auto max-h-[200px] md:max-h-[260px]">
{product.description}
</div>
</div>
);
}

View File

@@ -0,0 +1,214 @@
"use client";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
import { ProductDetail } from "@/lib/api/fundmarket";
interface OverviewItemProps {
icon: string;
label: string;
value: string;
}
function OverviewItem({ icon, label, value }: OverviewItemProps) {
return (
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between w-full">
<div className="flex items-center gap-1">
<div className="w-5 h-5 flex-shrink-0">
<Image src={icon} alt={label} width={20} height={20} />
</div>
<span className="text-xs font-medium leading-[150%] text-[#9ca1af] dark:text-gray-400">
{label}
</span>
</div>
<span className="text-sm font-semibold leading-[150%] text-[#111827] dark:text-white pl-6 md:pl-0">
{value}
</span>
</div>
);
}
interface AssetOverviewCardProps {
product: ProductDetail;
vaultInfo?: any[];
isVaultLoading?: boolean;
vaultTimedOut?: boolean;
}
export default function AssetOverviewCard({
product,
vaultInfo,
isVaultLoading,
vaultTimedOut,
}: AssetOverviewCardProps) {
const { t } = useApp();
const hasContract = !!product.contractAddress;
const vaultReady = !hasContract || vaultInfo !== undefined || vaultTimedOut;
const loading = hasContract && isVaultLoading && !vaultTimedOut;
// nextRedemptionTime: getVaultInfo[8]
const nextRedemptionTime = vaultInfo ? Number(vaultInfo[8]) : 0;
const maturityDisplay = (() => {
if (loading) return '...';
if (!nextRedemptionTime) return '--';
return new Date(nextRedemptionTime * 1000).toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric',
});
})();
// ytPrice: getVaultInfo[7], 30 decimals
const ytPriceRaw: bigint = vaultInfo ? (vaultInfo[7] as bigint) ?? 0n : 0n;
const currentPriceDisplay = (() => {
if (loading) return '...';
if (!ytPriceRaw || ytPriceRaw <= 0n) return '--';
const divisor = 10n ** 30n;
const intPart = ytPriceRaw / divisor;
const fracScaled = ((ytPriceRaw % divisor) * 1_000_000n) / divisor;
return `$${intPart}.${fracScaled.toString().padStart(6, '0')}`;
})();
// Pool Capacity %: totalSupply[4] / hardCap[5]
const totalSupplyRaw: bigint = vaultInfo ? (vaultInfo[4] as bigint) ?? 0n : 0n;
const hardCapRaw: bigint = vaultInfo ? (vaultInfo[5] as bigint) ?? 0n : 0n;
const livePoolCapPercent = (vaultReady && hardCapRaw > 0n)
? Math.min((Number(totalSupplyRaw) / Number(hardCapRaw)) * 100, 100)
: null;
const displayPoolCapPercent = livePoolCapPercent !== null
? livePoolCapPercent
: vaultReady ? product.poolCapacityPercent : null;
const poolCapDisplay = !vaultReady
? '...'
: `${(displayPoolCapPercent ?? 0).toFixed(4)}%`;
// Format USD values
const formatUSD = (value: number) => {
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`;
return `$${value.toFixed(2)}`;
};
// Risk badge
const getRiskColor = (riskLevel: number) => {
switch (riskLevel) {
case 1: return { bg: "#e1f8ec", border: "#b8ecd2", color: "#10b981" };
case 2: return { bg: "#fffbf5", border: "#ffedd5", color: "#ffb933" };
case 3: return { bg: "#fee2e2", border: "#fecaca", color: "#ef4444" };
default: return { bg: "#fffbf5", border: "#ffedd5", color: "#ffb933" };
}
};
const riskColors = getRiskColor(product.riskLevel);
const getRiskBars = () => {
const bars = [
{ height: 5, active: product.riskLevel >= 1 },
{ height: 7, active: product.riskLevel >= 2 },
{ height: 11, active: product.riskLevel >= 3 },
];
return bars.map((bar, i) => (
<div
key={i}
className="w-[3px] rounded-sm flex-shrink-0"
style={{
height: `${bar.height}px`,
backgroundColor: bar.active ? riskColors.color : '#d1d5db',
}}
/>
));
};
return (
<div
className="bg-white dark:bg-gray-800 border border-[#f3f4f6] dark:border-gray-700 flex flex-col rounded-2xl md:rounded-3xl p-4 md:p-8 gap-5 md:gap-6"
>
{/* Header */}
<div className="flex items-center justify-between w-full">
<h3 className="text-lg font-bold leading-[150%] text-[#111827] dark:text-white">
{t("assetOverview.title")}
</h3>
<div
className="rounded-full border flex items-center"
style={{
backgroundColor: riskColors.bg,
borderColor: riskColors.border,
padding: '6px 12px',
gap: '8px',
}}
>
<div
className="rounded-full flex-shrink-0"
style={{ backgroundColor: riskColors.color, width: '6px', height: '6px' }}
/>
<span className="text-xs font-semibold leading-4" style={{ color: riskColors.color }}>
{product.riskLabel}
</span>
<div className="flex items-end gap-[2px]">{getRiskBars()}</div>
</div>
</div>
{/* Overview Items - 2 per row */}
<div className="flex flex-col w-full" style={{ gap: '16px' }}>
<div className="grid grid-cols-2 gap-x-4 gap-y-5 w-full">
<OverviewItem
icon="/components/product/component-11.svg"
label={t("assetOverview.underlyingAssets")}
value={product.underlyingAssets}
/>
<OverviewItem
icon="/components/product/component-12.svg"
label={t("assetOverview.maturityRange")}
value={maturityDisplay}
/>
<OverviewItem
icon="/components/product/component-13.svg"
label={t("assetOverview.cap")}
value={formatUSD(product.poolCapUsd)}
/>
<OverviewItem
icon="/components/product/component-15.svg"
label={t("assetOverview.poolCapacity")}
value={poolCapDisplay}
/>
</div>
{/* Progress Bar - full width */}
<div
className="w-full bg-[#f3f4f6] dark:bg-gray-600 rounded-full overflow-hidden"
style={{ height: '10px' }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${displayPoolCapPercent ?? 0}%`,
background: 'linear-gradient(90deg, #1447e6 0%, #032bbd 100%)',
}}
/>
</div>
</div>
{/* Divider */}
<div className="w-full border-t border-[#f3f4f6] dark:border-gray-700" style={{ height: '1px' }} />
{/* Current Price */}
<div
className="bg-[#f9fafb] dark:bg-gray-700 border border-[#f3f4f6] dark:border-gray-600 flex flex-col md:flex-row items-center justify-center md:justify-between gap-2 w-full"
style={{ borderRadius: '16px', padding: '16px' }}
>
<div className="flex items-center" style={{ gap: '12px' }}>
<div className="w-5 h-6 flex-shrink-0">
<Image src="/components/product/component-16.svg" alt="Price" width={20} height={24} />
</div>
<span
className="text-sm font-medium uppercase"
style={{ color: '#4b5563', letterSpacing: '0.7px', lineHeight: '20px' }}
>
{t("assetOverview.currentPrice")}
</span>
</div>
<div className="text-xl font-bold leading-[140%]">
<span className="text-[#111827] dark:text-white">1 {product.tokenSymbol} = </span>
<span style={{ color: "#10b981" }}>{currentPriceDisplay}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import TabNavigation from "./TabNavigation";
import OverviewTab from "./OverviewTab";
import { useApp } from "@/contexts/AppContext";
import { ProductDetail } from "@/lib/api/fundmarket";
interface ContentSectionProps {
product: ProductDetail;
}
export default function ContentSection({ product }: ContentSectionProps) {
const { t } = useApp();
const tabs = [
{ id: "overview", label: t("tabs.overview") },
{ id: "asset-description", label: t("tabs.assetDescription") },
{ id: "performance-analysis", label: t("tabs.performanceAnalysis") },
{ id: "asset-custody", label: t("tabs.assetCustody") },
];
const handleTabChange = (tabId: string) => {
if (tabId !== "overview") {
setTimeout(() => {
document.getElementById(tabId)?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 100);
}
};
return (
<div className="flex flex-col gap-6 w-full">
<TabNavigation
tabs={tabs}
defaultActiveId="overview"
onTabChange={handleTabChange}
/>
<OverviewTab product={product} />
</div>
);
}

View File

@@ -0,0 +1,503 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
import { Tabs, Tab, Button } from "@heroui/react";
import ReviewModal from "@/components/common/ReviewModal";
import WithdrawModal from "@/components/common/WithdrawModal";
import { buttonStyles } from "@/lib/buttonStyles";
import { useAccount, useReadContract } from 'wagmi';
import { useQuery } from '@tanstack/react-query';
import { fetchContracts } from '@/lib/api/contracts';
import { abis } from '@/lib/contracts';
import { useUSDCBalance, useTokenBalance } from '@/hooks/useBalance';
import { useDeposit } from '@/hooks/useDeposit';
import { useWithdraw } from '@/hooks/useWithdraw';
import { getTxUrl } from '@/lib/contracts';
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
import { toast } from "sonner";
import TokenSelector from '@/components/common/TokenSelector';
import { Token } from '@/lib/api/tokens';
import { useAppKit } from "@reown/appkit/react";
interface MintSwapPanelProps {
tokenType?: string;
decimals?: number;
onVaultRefresh?: () => void;
}
export default function MintSwapPanel({ tokenType = 'YT-A', decimals, onVaultRefresh }: MintSwapPanelProps) {
const { t } = useApp();
const [activeAction, setActiveAction] = useState<"deposit" | "withdraw">("deposit");
const [amount, setAmount] = useState<string>("");
const [selectedToken, setSelectedToken] = useState<Token | undefined>();
const [isReviewModalOpen, setIsReviewModalOpen] = useState(false);
const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false);
const [mounted, setMounted] = useState(false);
// 避免 hydration 错误
useEffect(() => {
setMounted(true);
}, []);
// Web3 集成
const { isConnected } = useAccount();
const { open } = useAppKit();
const { formattedBalance: usdcBalance, isLoading: isBalanceLoading, refetch: refetchBalance } = useUSDCBalance();
const ytToken = useTokenBySymbol(tokenType ?? '');
const { formattedBalance: ytBalance, refetch: refetchYT } = useTokenBalance(
ytToken?.contractAddress,
ytToken?.decimals ?? decimals ?? 18
);
const {
status: depositStatus,
error: depositError,
isLoading: isDepositLoading,
approveHash,
depositHash,
executeApproveAndDeposit,
reset: resetDeposit,
} = useDeposit(ytToken);
const {
status: withdrawStatus,
error: withdrawError,
isLoading: isWithdrawLoading,
approveHash: withdrawApproveHash,
withdrawHash,
executeApproveAndWithdraw,
reset: resetWithdraw,
} = useWithdraw(ytToken);
// 从合约读取 nextRedemptionTime复用 contract-registry 缓存
const { data: contractConfigs = [] } = useQuery({
queryKey: ['contract-registry'],
queryFn: fetchContracts,
staleTime: 5 * 60 * 1000,
});
const vaultChainId = ytToken?.chainId ?? 97;
const factoryAddress = useMemo(() => {
const c = contractConfigs.find(c => c.name === 'YTAssetFactory' && c.chain_id === vaultChainId);
return c?.address as `0x${string}` | undefined;
}, [contractConfigs, vaultChainId]);
const priceFeedAddress = useMemo(() => {
const c = contractConfigs.find(c => c.name === 'YTPriceFeed' && c.chain_id === vaultChainId);
return c?.address as `0x${string}` | undefined;
}, [contractConfigs, vaultChainId]);
// Read selected token price from YTPriceFeed (30 dec precision)
const { data: tokenPriceRaw } = useReadContract({
address: priceFeedAddress,
abi: abis.YTPriceFeed as any,
functionName: 'getPrice',
args: selectedToken?.contractAddress
? [selectedToken.contractAddress as `0x${string}`, false]
: undefined,
chainId: vaultChainId,
query: { enabled: !!priceFeedAddress && !!selectedToken?.contractAddress },
});
const selectedTokenUSDPrice = tokenPriceRaw && (tokenPriceRaw as bigint) > 0n
? Number(tokenPriceRaw as bigint) / 1e30
: null; // null = price not yet loaded
const { data: vaultInfo, isLoading: isVaultInfoLoading } = useReadContract({
address: factoryAddress,
abi: abis.YTAssetFactory as any,
functionName: 'getVaultInfo',
args: ytToken?.contractAddress ? [ytToken.contractAddress as `0x${string}`] : undefined,
chainId: vaultChainId,
query: { enabled: !!factoryAddress && !!ytToken?.contractAddress },
});
// 超时 3 秒后不再等合约数据
const [vaultInfoTimedOut, setVaultInfoTimedOut] = useState(false);
useEffect(() => {
if (!factoryAddress || !ytToken?.contractAddress) return;
setVaultInfoTimedOut(false);
const t = setTimeout(() => setVaultInfoTimedOut(true), 3000);
return () => clearTimeout(t);
}, [factoryAddress, ytToken?.contractAddress]);
// nextRedemptionTime: getVaultInfo 返回值 index[8]
const nextRedemptionTime = vaultInfo ? Number((vaultInfo as any[])[8]) : 0;
const isMatured = nextRedemptionTime > 0 && nextRedemptionTime * 1000 < Date.now();
// isFull: totalSupply[4] >= hardCap[5]
const vaultTotalSupply: bigint = vaultInfo ? ((vaultInfo as any[])[4] as bigint) ?? 0n : 0n;
const vaultHardCap: bigint = vaultInfo ? ((vaultInfo as any[])[5] as bigint) ?? 0n : 0n;
const isFull = vaultHardCap > 0n && vaultTotalSupply >= vaultHardCap;
const maturityDateStr = nextRedemptionTime > 0
? new Date(nextRedemptionTime * 1000).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
: '';
// ytPrice: getVaultInfo 返回值 index[7]30 位小数
const ytPriceRaw: bigint = vaultInfo ? ((vaultInfo as any[])[7] as bigint) ?? 0n : 0n;
const ytPriceDisplay = (() => {
if (!ytPriceRaw || ytPriceRaw <= 0n) return '--';
const divisor = 10n ** 30n;
const intPart = ytPriceRaw / divisor;
const fracScaled = ((ytPriceRaw % divisor) * 1_000_000n) / divisor;
return `$${intPart}.${fracScaled.toString().padStart(6, '0')}`;
})();
const usdcToken = useTokenBySymbol('USDC');
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18;
const ytDecimals = ytToken?.onChainDecimals ?? ytToken?.decimals ?? decimals ?? 18;
const inputDecimals = activeAction === 'deposit' ? usdcDecimals : ytDecimals;
const displayDecimals = Math.min(inputDecimals, 6);
const truncateDecimals = (s: string, d: number) => { const p = s.split('.'); return p.length > 1 ? `${p[0]}.${p[1].slice(0, d)}` : s; };
const isValidAmount = (v: string) => v !== '' && !isNaN(parseFloat(v)) && parseFloat(v) > 0;
// ytPrice as float for You Get calculation (30 dec)
const ytPrice = ytPriceRaw > 0n ? Number(ytPriceRaw) / 1e30 : 0;
// You Get = selectedTokenUSDPrice × amount / ytPrice
const depositYouGet = ytPrice > 0 && selectedTokenUSDPrice !== null && isValidAmount(amount)
? (selectedTokenUSDPrice * parseFloat(amount)) / ytPrice
: null;
const handleAmountChange = (value: string) => {
if (value === '') { setAmount(value); return; }
if (!/^\d*\.?\d*$/.test(value)) return;
const parts = value.split('.');
if (parts.length > 1 && parts[1].length > displayDecimals) return;
const maxBalance = activeAction === 'deposit' ? parseFloat(usdcBalance) : parseFloat(ytBalance);
if (parseFloat(value) > maxBalance) {
setAmount(truncateDecimals(activeAction === 'deposit' ? usdcBalance : ytBalance, displayDecimals));
return;
}
setAmount(value);
};
const DEPOSIT_TOAST_ID = 'mint-deposit-tx';
const WITHDRAW_TOAST_ID = 'mint-withdraw-tx';
// Approve 交易提交 toast
useEffect(() => {
if (approveHash) {
toast.loading(t("mintSwap.toast.approvalSubmitted"), {
id: DEPOSIT_TOAST_ID,
description: t("mintSwap.toast.waitingConfirmation"),
action: { label: t("mintSwap.toast.viewTx"), onClick: () => window.open(getTxUrl(approveHash), '_blank') },
});
}
}, [approveHash]);
useEffect(() => {
if (withdrawApproveHash) {
toast.loading(t("mintSwap.toast.approvalSubmitted"), {
id: WITHDRAW_TOAST_ID,
description: t("mintSwap.toast.waitingConfirmation"),
action: { label: t("mintSwap.toast.viewTx"), onClick: () => window.open(getTxUrl(withdrawApproveHash), '_blank') },
});
}
}, [withdrawApproveHash]);
// 主交易提交 toast
useEffect(() => {
if (depositHash && depositStatus === 'depositing') {
toast.loading(t("mintSwap.toast.depositSubmitted"), {
id: DEPOSIT_TOAST_ID,
description: t("mintSwap.toast.waitingConfirmation"),
action: { label: t("mintSwap.toast.viewTx"), onClick: () => window.open(getTxUrl(depositHash), '_blank') },
});
}
}, [depositHash, depositStatus]);
useEffect(() => {
if (withdrawHash && withdrawStatus === 'withdrawing') {
toast.loading(t("mintSwap.toast.withdrawSubmitted"), {
id: WITHDRAW_TOAST_ID,
description: t("mintSwap.toast.waitingConfirmation"),
action: { label: t("mintSwap.toast.viewTx"), onClick: () => window.open(getTxUrl(withdrawHash), '_blank') },
});
}
}, [withdrawHash, withdrawStatus]);
// 存款成功后刷新余额
useEffect(() => {
if (depositStatus === 'success') {
toast.success(t("mintSwap.toast.depositSuccess"), {
id: DEPOSIT_TOAST_ID,
description: t("mintSwap.toast.depositSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchYT();
onVaultRefresh?.();
const timer1 = setTimeout(() => { refetchBalance(); refetchYT(); onVaultRefresh?.(); }, 3000);
const timer2 = setTimeout(() => { resetDeposit(); setAmount(""); }, 6000);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [depositStatus]);
// 取款成功后刷新余额
useEffect(() => {
if (withdrawStatus === 'success') {
toast.success(t("mintSwap.toast.withdrawSuccess"), {
id: WITHDRAW_TOAST_ID,
description: t("mintSwap.toast.withdrawSuccessDesc"),
duration: 5000,
});
refetchBalance();
refetchYT();
onVaultRefresh?.();
const timer1 = setTimeout(() => { refetchBalance(); refetchYT(); onVaultRefresh?.(); }, 3000);
const timer2 = setTimeout(() => { resetWithdraw(); setAmount(""); }, 6000);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [withdrawStatus]);
// 错误 toast
useEffect(() => {
if (depositError) {
if (depositError === 'Transaction cancelled') {
toast.dismiss(DEPOSIT_TOAST_ID);
} else {
toast.error(t("mintSwap.toast.depositFailed"), { id: DEPOSIT_TOAST_ID, duration: 5000 });
}
}
}, [depositError]);
useEffect(() => {
if (withdrawError) {
if (withdrawError === 'Transaction cancelled') {
toast.dismiss(WITHDRAW_TOAST_ID);
} else {
toast.error(t("mintSwap.toast.withdrawFailed"), { id: WITHDRAW_TOAST_ID, duration: 5000 });
}
}
}, [withdrawError]);
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden">
{/* Content */}
<div className="flex flex-col gap-4 md:gap-6 p-4 md:p-6">
{/* Deposit/Withdraw Toggle */}
<Tabs
selectedKey={activeAction}
onSelectionChange={(key) => {
const next = key as "deposit" | "withdraw";
setActiveAction(next);
setAmount("");
if (next === "deposit") { resetWithdraw(); } else { resetDeposit(); }
}}
variant="solid"
classNames={{
base: "w-full",
tabList: "bg-[#f9fafb] dark:bg-gray-700 rounded-xl p-1 gap-0 w-full",
cursor: "bg-bg-surface dark:bg-gray-600 shadow-sm",
tab: "h-8 px-4",
tabContent: "text-body-small font-medium text-text-tertiary dark:text-gray-400 group-data-[selected=true]:font-bold group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
}}
>
<Tab key="deposit" title={t("mintSwap.deposit")} />
<Tab key="withdraw" title={t("mintSwap.withdraw")} />
</Tabs>
{/* Input Area */}
<div className="flex flex-col gap-2">
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-3">
{/* Label and Balance */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{activeAction === 'deposit' ? t("mintSwap.deposit") : t("mintSwap.withdraw")}
</span>
<div className="flex items-center gap-1">
<Image src="/components/common/icon0.svg" alt="" width={12} height={12} />
<span className="text-caption-tiny font-medium text-[#4b5563] dark:text-gray-400">
{t("mintSwap.balance")}: {!mounted ? '0' : (isBalanceLoading ? '...' : activeAction === 'deposit'
? `$${parseFloat(truncateDecimals(usdcBalance, displayDecimals)).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })}`
: `${parseFloat(truncateDecimals(ytBalance, displayDecimals)).toLocaleString('en-US', { maximumFractionDigits: displayDecimals })} ${tokenType}`)}
</span>
</div>
</div>
{/* Input Row */}
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start flex-1">
<input
type="text" inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
className="w-full text-left text-heading-h3 font-bold text-text-primary dark:text-white placeholder:text-[#d1d5db] dark:placeholder:text-gray-500 bg-transparent border-none outline-none"
/>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">
{amount ? (activeAction === 'deposit' ? `$${amount}` : `$${amount} USDC`) : "--"}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
className={buttonStyles({ intent: "max" })}
onPress={() => setAmount(truncateDecimals(activeAction === 'deposit' ? usdcBalance : ytBalance, displayDecimals))}
>
{t("mintSwap.max")}
</Button>
{activeAction === 'deposit' ? (
<TokenSelector
selectedToken={selectedToken}
onSelect={setSelectedToken}
filterTypes={['stablecoin']}
/>
) : (
<div className="bg-white dark:bg-gray-700 rounded-full px-4 h-[46px] flex items-center gap-2">
<Image
src={ytToken?.iconUrl || '/assets/tokens/default.svg'}
alt={tokenType}
width={32}
height={32}
className="rounded-full"
onError={(e) => { (e.target as HTMLImageElement).src = '/assets/tokens/default.svg'; }}
/>
<span className="text-body-default font-bold text-text-primary dark:text-white">{tokenType}</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Your YT Balance */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-2">
<span className="text-body-small font-bold text-[#4b5563] dark:text-gray-300">
{t("mintSwap.yourTokenBalance").replace('{token}', tokenType)}
</span>
<div className="flex items-center justify-between gap-2">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 shrink-0">
{t("mintSwap.currentBalance")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 text-right min-w-0 truncate">
{!mounted ? '0' : `${parseFloat(ytBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })} ${tokenType}`}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 shrink-0">
{t("mintSwap.valueUsdc")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white whitespace-nowrap">
{!mounted ? '$0' : `$${parseFloat(ytBalance).toLocaleString('en-US', { maximumFractionDigits: 6 })}`}
</span>
</div>
</div>
{/* Transaction Summary */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 p-4 flex flex-col gap-2">
<span className="text-body-small font-bold text-[#4b5563] dark:text-gray-300">
{t("mintSwap.transactionSummary")}
</span>
<div className="flex items-center justify-between gap-2">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 shrink-0">
{t("mintSwap.youGet")}
</span>
<span className="text-body-small font-bold text-[#10b981] dark:text-green-400 text-right min-w-0 truncate">
{isValidAmount(amount)
? activeAction === 'deposit'
? depositYouGet !== null
? `${depositYouGet.toLocaleString('en-US', { maximumFractionDigits: 6 })} ${tokenType}`
: (!selectedToken ? '--' : '...')
: `$${parseFloat(amount).toLocaleString('en-US', { maximumFractionDigits: 6 })} USDC`
: '--'}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-body-small font-regular text-text-tertiary dark:text-gray-400 shrink-0">
{t("mintSwap.salesPrice")}
</span>
<span className="text-body-small font-bold text-text-primary dark:text-white whitespace-nowrap">
{isVaultInfoLoading && !vaultInfoTimedOut ? '...' : ytPriceDisplay}
</span>
</div>
</div>
{/* Submit Button */}
<Button
isDisabled={isConnected && (
!isValidAmount(amount) ||
isDepositLoading ||
isWithdrawLoading ||
((isMatured || isFull) && activeAction === 'deposit') ||
(!isMatured && activeAction === 'withdraw')
)}
color="default"
variant="solid"
className={buttonStyles({ intent: "theme" })}
endContent={<Image src="/components/common/icon11.svg" alt="" width={20} height={20} />}
onPress={() => {
if (!isConnected) { open(); return; }
if (amount && parseFloat(amount) > 0) {
if (activeAction === "deposit") {
executeApproveAndDeposit(amount);
} else {
executeApproveAndWithdraw(amount);
}
}
}}
>
{!isConnected
? t("common.connectWallet")
: isFull && activeAction === 'deposit'
? t("mintSwap.poolFull")
: isMatured && activeAction === 'deposit'
? t("mintSwap.productMatured").replace("{date}", maturityDateStr)
: activeAction === 'deposit'
? depositStatus === 'approving' ? t("common.approving")
: depositStatus === 'approved' ? t("mintSwap.approvedDepositing")
: depositStatus === 'depositing' ? t("mintSwap.depositing")
: depositStatus === 'success' ? t("common.success")
: depositStatus === 'error' ? t("common.failed")
: !!amount && !isValidAmount(amount) ? t("common.invalidAmount")
: t("mintSwap.approveDeposit")
: !isMatured
? nextRedemptionTime > 0
? t("mintSwap.withdrawNotMatured").replace("{date}", maturityDateStr)
: t("mintSwap.withdrawNoMaturity")
: withdrawStatus === 'approving' ? t("common.approving")
: withdrawStatus === 'approved' ? t("mintSwap.approvedWithdrawing")
: withdrawStatus === 'withdrawing' ? t("mintSwap.withdrawing")
: withdrawStatus === 'success' ? t("common.success")
: withdrawStatus === 'error' ? t("common.failed")
: !!amount && !isValidAmount(amount) ? t("common.invalidAmount")
: t("mintSwap.approveWithdraw")
}
</Button>
{/* Review Modal for Deposit */}
<ReviewModal
isOpen={isReviewModalOpen}
onClose={() => setIsReviewModalOpen(false)}
amount={amount}
/>
{/* Withdraw Modal */}
<WithdrawModal
isOpen={isWithdrawModalOpen}
onClose={() => setIsWithdrawModalOpen(false)}
amount={amount}
/>
{/* Terms */}
<div className="flex flex-col gap-0 text-center">
<div className="text-caption-tiny font-regular">
<span className="text-[#9ca1af] dark:text-gray-400">
{t("mintSwap.termsText")}{" "}
</span>
<span className="text-[#10b981] dark:text-green-400">
{t("mintSwap.termsOfService")}
</span>
<span className="text-[#9ca1af] dark:text-gray-400">
{" "}{t("mintSwap.and")}
</span>
</div>
<span className="text-caption-tiny font-regular text-[#10b981] dark:text-green-400">
{t("mintSwap.privacyPolicy")}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect, useMemo } from 'react';
import { useAccount, useReadContract } from 'wagmi';
import { bscTestnet } from 'wagmi/chains';
import { useQuery } from '@tanstack/react-query';
import { useTokenBalance } from '@/hooks/useBalance';
import { useTokenBySymbol } from '@/hooks/useTokenBySymbol';
import { useApp } from '@/contexts/AppContext';
import { fetchContracts } from '@/lib/api/contracts';
import { abis } from '@/lib/contracts';
import ProductHeader from "./ProductHeader";
import StatsCards from "@/components/fundmarket/StatsCards";
import AssetOverviewCard from "./AssetOverviewCard";
import APYHistoryCard from "./APYHistoryCard";
import AssetDescriptionCard from "./AssetDescriptionCard";
import MintSwapPanel from "./MintSwapPanel";
import ProtocolInformation from "./ProtocolInformation";
import PerformanceAnalysis from "./PerformanceAnalysis";
import Season1Rewards from "./Season1Rewards";
import AssetCustodyVerification from "./AssetCustodyVerification";
import { ProductDetail } from "@/lib/api/fundmarket";
interface OverviewTabProps {
product: ProductDetail;
}
const formatUSD = (v: number) =>
"$" + v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
export default function OverviewTab({ product }: OverviewTabProps) {
const { t } = useApp();
const { isConnected, chainId } = useAccount();
const ytToken = useTokenBySymbol(product.tokenSymbol);
const { formattedBalance: ytBalance } = useTokenBalance(ytToken?.contractAddress, ytToken?.decimals ?? 18);
// Shared getVaultInfo read — single source of truth for all children
const { data: contractConfigs = [] } = useQuery({
queryKey: ['contract-registry'],
queryFn: fetchContracts,
staleTime: 5 * 60 * 1000,
});
const factoryAddress = useMemo(() => {
const c = contractConfigs.find(c => c.name === 'YTAssetFactory' && c.chain_id === product.chainId);
return c?.address as `0x${string}` | undefined;
}, [contractConfigs, product.chainId]);
const hasContract = !!factoryAddress && !!product.contractAddress;
const { data: vaultInfo, isLoading: isVaultLoading, refetch: refetchVault } = useReadContract({
address: factoryAddress,
abi: abis.YTAssetFactory as any,
functionName: 'getVaultInfo',
args: product.contractAddress ? [product.contractAddress as `0x${string}`] : undefined,
chainId: product.chainId,
query: { enabled: hasContract },
});
// 3s timeout — after this, fall back to DB snapshot values
const [vaultTimedOut, setVaultTimedOut] = useState(false);
useEffect(() => {
if (!hasContract) return;
setVaultTimedOut(false);
const t = setTimeout(() => setVaultTimedOut(true), 3000);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasContract]);
const vaultReady = !hasContract || vaultInfo !== undefined || vaultTimedOut;
// Real-time TVL: getVaultInfo[1] = totalAssets (USDC)
const usdcDecimals = product.chainId === 421614 ? 6 : 18;
const totalAssetsRaw: bigint = vaultInfo ? ((vaultInfo as any[])[1] as bigint) ?? 0n : 0n;
const liveTVL = totalAssetsRaw > 0n ? Number(totalAssetsRaw) / Math.pow(10, usdcDecimals) : null;
const tvlDisplay = !vaultReady
? "..."
: liveTVL !== null
? formatUSD(liveTVL)
: formatUSD(product.tvlUsd);
const balanceUSD = parseFloat(ytBalance) * (product.currentPrice || 1);
const balanceDisplay = !isConnected
? "--"
: chainId !== bscTestnet.id
? "-- (Switch to BSC)"
: formatUSD(balanceUSD);
const volume24hDisplay = product.volume24hUsd > 0 ? formatUSD(product.volume24hUsd) : "--";
const volChange = product.volumeChangeVsAvg ?? 0;
const volChangeStr = volChange !== 0
? `${volChange > 0 ? "↑" : "↓"} ${Math.abs(volChange).toFixed(0)}% vs Avg`
: "";
const stats = [
{ label: t("productPage.totalValueLocked"), value: tvlDisplay, change: "", isPositive: true },
{ label: t("productPage.volume24h"), value: volume24hDisplay, change: volChangeStr, isPositive: volChange >= 0 },
{ label: t("productPage.cumulativeYield"), value: formatUSD(0), change: "", isPositive: true },
{ label: t("productPage.yourTotalBalance"), value: balanceDisplay, change: "", isPositive: true },
{ label: t("productPage.yourTotalEarning"), value: "--", change: "", isPositive: true },
];
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8 w-full">
{/* ① Header + Stats — 始终在最顶部桌面端跨3列 */}
<div className="order-1 md:col-span-3 min-w-0">
<div className="bg-white dark:bg-gray-800 border border-[#f3f4f6] dark:border-gray-700 flex flex-col overflow-hidden rounded-2xl md:rounded-3xl p-4 md:p-8 gap-5 md:gap-6">
<ProductHeader product={product} />
<StatsCards stats={stats} />
</div>
</div>
{/* ② 交易板 + Protocol — 移动端排第2桌面端在右列 */}
<div className="order-2 md:order-3 md:col-span-1 min-w-0 flex flex-col gap-6 md:gap-8">
<MintSwapPanel
tokenType={product.tokenSymbol}
decimals={product.decimals}
onVaultRefresh={refetchVault}
/>
<ProtocolInformation product={product} />
</div>
{/* ③ Asset Overview + APY + Description — 移动端排第3桌面端在左列 */}
<div className="order-3 md:order-2 md:col-span-2 min-w-0 flex flex-col gap-6 md:gap-8">
<AssetOverviewCard
product={product}
vaultInfo={vaultInfo as any[] | undefined}
isVaultLoading={isVaultLoading}
vaultTimedOut={vaultTimedOut}
/>
<APYHistoryCard productId={product.id} />
<div id="asset-description">
<AssetDescriptionCard product={product} />
</div>
</div>
{/* ④ Season 1 Rewards — 全宽 */}
<div className="order-4 md:col-span-3 min-w-0">
<Season1Rewards />
</div>
{/* ⑤ Performance Analysis (Daily Net Returns) — 全宽 */}
<div id="performance-analysis" className="order-5 md:col-span-3 min-w-0">
<PerformanceAnalysis productId={product.id} />
</div>
{/* ⑥ Asset Custody & Verification — 全宽 */}
<div id="asset-custody" className="order-6 md:col-span-3 min-w-0">
<AssetCustodyVerification product={product} />
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
import { fetchDailyReturns, DailyReturnPoint } from "@/lib/api/fundmarket";
interface PerformanceAnalysisProps {
productId: number;
}
type DayType = "positive" | "negative" | "neutral" | "current";
interface CalendarDayProps {
day: number | null;
value: string;
type: DayType;
}
function CalendarDay({ day, value, type }: CalendarDayProps) {
if (day === null) return <div className="flex-1" />;
const typeStyles: Record<DayType, string> = {
positive: "bg-[#f2fcf7] dark:bg-green-900/20 border-[#cef3e0] dark:border-green-700/30",
negative: "bg-[#fff8f7] dark:bg-red-900/20 border-[#ffdbd5] dark:border-red-700/30",
neutral: "bg-[#f9fafb] dark:bg-gray-700 border-[#f3f4f6] dark:border-gray-600",
current: "bg-[#111827] dark:bg-[#111827] border-[#111827]",
};
const dayTextStyle = type === "current" ? "text-[#fcfcfd]" : "text-[#9ca1af] dark:text-gray-400";
const valueTextStyle =
type === "current" ? "text-[#10b981]" :
type === "positive" ? "text-[#10b981] dark:text-green-400" :
type === "negative" ? "text-[#dc2626] dark:text-red-400" :
"text-[#9ca1af] dark:text-gray-400";
return (
<div className={`rounded border flex flex-col items-center justify-center flex-1 p-1.5 md:p-3 gap-2 md:gap-6 ${typeStyles[type]}`}>
<div className="w-full flex items-start">
<span className={`text-[10px] font-bold leading-[150%] ${dayTextStyle}`}>{day}</span>
</div>
<div className="w-full flex flex-col items-end gap-1">
<span className={`text-[9px] md:text-body-small font-bold leading-[150%] ${valueTextStyle}`}>{value}</span>
{type === "current" && (
<span className="text-[10px] font-bold leading-[150%] tracking-[0.01em] text-[#9DA1AE]">Today</span>
)}
</div>
</div>
);
}
function buildCalendar(year: number, month: number, dataMap: Map<string, DailyReturnPoint>, today: string) {
const firstDay = new Date(year, month - 1, 1).getDay(); // 0=Sun
const daysInMonth = new Date(year, month, 0).getDate();
type Cell = { day: number | null; value: string; type: DayType };
const cells: Cell[] = [];
// Leading empty cells
for (let i = 0; i < firstDay; i++) cells.push({ day: null, value: "", type: "neutral" });
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const pt = dataMap.get(dateStr);
const isToday = dateStr === today;
const isFuture = dateStr > today;
if (isFuture) {
cells.push({ day: d, value: "--", type: "neutral" });
} else if (!pt || !pt.hasData) {
cells.push({ day: d, value: "--", type: "neutral" });
} else {
const r = pt.dailyReturn;
const label = r === 0 ? "0.00%" : `${r > 0 ? "+" : ""}${r.toFixed(2)}%`;
const type: DayType = isToday ? "current" : r > 0 ? "positive" : r < 0 ? "negative" : "neutral";
cells.push({ day: d, value: label, type });
}
}
// Trailing empty cells to complete the last row
while (cells.length % 7 !== 0) cells.push({ day: null, value: "", type: "neutral" });
// Split into weeks
const weeks: Cell[][] = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
return weeks;
}
export default function PerformanceAnalysis({ productId }: PerformanceAnalysisProps) {
const { t } = useApp();
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth() + 1);
const [dataMap, setDataMap] = useState<Map<string, DailyReturnPoint>>(new Map());
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
fetchDailyReturns(productId, year, month).then((pts) => {
const m = new Map<string, DailyReturnPoint>();
pts.forEach((p) => m.set(p.date, p));
setDataMap(m);
setIsLoading(false);
});
}, [productId, year, month]);
const todayStr = today.toISOString().slice(0, 10);
const weekData = buildCalendar(year, month, dataMap, todayStr);
const monthLabel = new Date(year, month - 1, 1).toLocaleString("en-US", { month: "long", year: "numeric" });
const prevMonth = () => {
if (month === 1) { setYear(y => y - 1); setMonth(12); }
else setMonth(m => m - 1);
};
const nextMonth = () => {
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1;
if (isCurrentMonth) return;
if (month === 12) { setYear(y => y + 1); setMonth(1); }
else setMonth(m => m + 1);
};
const isCurrentMonth = year === today.getFullYear() && month === today.getMonth() + 1;
return (
<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 md:gap-8">
{/* Calendar Section */}
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-start justify-between w-full">
<div className="flex items-center" style={{ gap: "8px" }}>
<div className="w-5 h-6 flex-shrink-0">
<Image src="/components/product/icon-performance-chart.svg" alt="" width={20} height={24} />
</div>
<h3 className="text-sm font-bold leading-5 text-[#0f172a] dark:text-white">
{t("performance.dailyNetReturns")}
</h3>
</div>
<div className="flex items-center" style={{ gap: "8px", height: "24px" }}>
<button
onClick={prevMonth}
className="rounded-lg flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ width: "24px", height: "24px" }}
>
<Image src="/components/common/icon-arrow-left.svg" alt="Previous" width={16} height={16} />
</button>
<span className="text-sm font-bold text-[#0a0a0a] dark:text-white" style={{ letterSpacing: "-0.15px", lineHeight: "20px" }}>
{monthLabel}
</span>
<button
onClick={nextMonth}
disabled={isCurrentMonth}
className="rounded-lg flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
style={{ width: "24px", height: "24px" }}
>
<Image src="/components/common/icon-arrow-right.svg" alt="Next" width={16} height={16} />
</button>
</div>
</div>
{/* Calendar */}
<div className="flex flex-col gap-4">
<div className="grid grid-cols-7 gap-1 md:gap-2">
{["sun", "mon", "tue", "wed", "thu", "fri", "sat"].map((day) => (
<div key={day} className="flex items-center justify-center">
<span className="text-[10px] font-bold leading-[150%] text-[#94a3b8] dark:text-gray-400">
{t(`performance.weekdays.${day}`)}
</span>
</div>
))}
</div>
{isLoading ? (
<div className="h-40 flex items-center justify-center text-[#9ca1af] text-sm">Loading...</div>
) : (
<div className="flex flex-col gap-1">
{weekData.map((week, wi) => (
<div key={wi} className="grid grid-cols-7 gap-1 md:gap-2">
{week.map((day, di) => (
<CalendarDay key={`${wi}-${di}`} day={day.day} value={day.value} type={day.type} />
))}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
"use client";
import { Skeleton } from "@heroui/react";
export default function ProductDetailSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8 w-full">
{/* ① Header + StatsCards — 同一卡片跨3列 */}
<div className="order-1 md:col-span-3 min-w-0">
<div className="bg-white dark:bg-gray-800 border border-[#f3f4f6] dark:border-gray-700 rounded-2xl md:rounded-3xl p-4 md:p-8 flex flex-col gap-5 md:gap-6">
{/* ProductHeader */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center gap-4">
<Skeleton className="w-12 h-12 md:w-16 md:h-16 rounded-2xl flex-shrink-0" />
<div className="flex flex-col gap-2">
<Skeleton className="h-6 w-28 md:w-32 rounded-lg" />
<Skeleton className="h-4 w-40 md:w-56 rounded" />
<div className="flex gap-2 mt-1">
<Skeleton className="h-5 w-16 md:w-20 rounded-full" />
<Skeleton className="h-5 w-20 md:w-24 rounded-full" />
</div>
</div>
</div>
<Skeleton className="h-8 w-full md:w-36 rounded-xl" />
</div>
{/* StatsCards — 移动端2列桌面端5列 */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="rounded-2xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-5 flex flex-col gap-3">
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="h-7 w-16 rounded-lg" />
</div>
))}
</div>
</div>
</div>
{/* ② 交易板 + Protocol — 移动端排第2桌面端右列 */}
<div className="order-2 md:order-3 md:col-span-1 min-w-0 flex flex-col gap-6 md:gap-8">
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-6 flex flex-col gap-4">
<Skeleton className="h-10 w-full rounded-xl" />
<Skeleton className="h-14 w-full rounded-2xl" />
<Skeleton className="h-14 w-full rounded-2xl" />
<Skeleton className="h-4 w-full rounded" />
<Skeleton className="h-12 w-full rounded-2xl" />
</div>
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-6 flex flex-col gap-4">
<Skeleton className="h-5 w-36 rounded" />
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-4 w-4 rounded" />
</div>
))}
</div>
</div>
{/* ③ Asset Overview + APY + Description — 移动端排第3桌面端左列 */}
<div className="order-3 md:order-2 md:col-span-2 min-w-0 flex flex-col gap-6 md:gap-8">
{/* AssetOverviewCard */}
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-8 flex flex-col gap-5">
<div className="flex justify-between items-center">
<Skeleton className="h-5 w-28 rounded" />
<Skeleton className="h-6 w-16 rounded-full" />
</div>
<div className="grid grid-cols-2 gap-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex justify-between">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-4 w-16 rounded" />
</div>
))}
</div>
<Skeleton className="h-px w-full rounded" />
<Skeleton className="h-12 w-full rounded-2xl" />
</div>
{/* APYHistoryCard */}
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-6 flex flex-col gap-5">
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-28 rounded" />
<div className="flex gap-2">
<Skeleton className="h-7 w-16 rounded-full" />
<Skeleton className="h-7 w-16 rounded-full" />
</div>
</div>
<Skeleton className="h-[140px] md:h-[160px] w-full rounded-xl" />
</div>
{/* AssetDescriptionCard */}
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-6 flex flex-col gap-4">
<Skeleton className="h-5 w-32 rounded" />
<div className="flex flex-col gap-2">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-4 w-full rounded" />
))}
<Skeleton className="h-4 w-2/3 rounded" />
</div>
</div>
</div>
{/* ④ Season1Rewards — 全宽 */}
<div className="order-4 md:col-span-3 min-w-0">
<Skeleton className="h-24 md:h-28 w-full rounded-3xl" />
</div>
{/* ⑤ PerformanceAnalysis — 全宽 */}
<div className="order-5 md:col-span-3 min-w-0">
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-8 flex flex-col gap-5">
<div className="flex justify-between items-center">
<Skeleton className="h-5 w-40 rounded" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-20 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: 35 }).map((_, i) => (
<Skeleton key={i} className="h-8 md:h-10 w-full rounded-lg" />
))}
</div>
</div>
</div>
{/* ⑥ AssetCustodyVerification — 全宽 */}
<div className="order-6 md:col-span-3 min-w-0 flex flex-col gap-8">
<div className="flex flex-col gap-2">
<Skeleton className="h-6 w-48 rounded" />
<Skeleton className="h-4 w-80 rounded" />
</div>
<div className="bg-white dark:bg-gray-800 rounded-3xl border border-[#f3f4f6] dark:border-gray-700 p-4 md:p-8 flex flex-col gap-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 pb-6 border-b border-[#f3f4f6] dark:border-gray-700">
<Skeleton className="h-5 w-40 rounded" />
<Skeleton className="h-4 w-48 rounded" />
</div>
<div className="hidden md:grid grid-cols-5 gap-4 pb-4 border-b border-[#f3f4f6] dark:border-gray-700">
{[0, 1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-3 w-20 rounded" />
))}
</div>
<div className="hidden md:grid grid-cols-5 gap-4 py-4">
{[0, 1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-5 w-full rounded" />
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
{[0, 1, 2].map((i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-2xl border border-[#f3f4f6] dark:border-gray-700 p-6 flex flex-col gap-4">
<div className="flex items-start gap-4">
<Skeleton className="w-5 h-5 rounded flex-shrink-0" />
<div className="flex flex-col gap-2 flex-1">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-3 w-full rounded" />
<Skeleton className="h-3 w-3/4 rounded" />
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="h-4 w-4 rounded" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import Image from "next/image";
import { toast } from "sonner";
import { useApp } from "@/contexts/AppContext";
import { useQuery } from "@tanstack/react-query";
import { useReadContract } from "wagmi";
import { fetchContracts } from "@/lib/api/contracts";
import { abis } from "@/lib/contracts";
import { ProductDetail } from "@/lib/api/fundmarket";
interface ProductHeaderProps {
product: ProductDetail;
}
export default function ProductHeader({ product }: ProductHeaderProps) {
const { t } = useApp();
const [copied, setCopied] = useState(false);
// 从合约读取状态
const { data: contractConfigs = [] } = useQuery({
queryKey: ['contract-registry'],
queryFn: fetchContracts,
staleTime: 5 * 60 * 1000,
});
const factoryAddress = useMemo(() => {
const c = contractConfigs.find(c => c.name === 'YTAssetFactory' && c.chain_id === product.chainId);
return c?.address as `0x${string}` | undefined;
}, [contractConfigs, product.chainId]);
const { data: vaultInfo, isLoading: isVaultLoading } = useReadContract({
address: factoryAddress,
abi: abis.YTAssetFactory as any,
functionName: 'getVaultInfo',
args: product.contractAddress ? [product.contractAddress as `0x${string}`] : undefined,
chainId: product.chainId,
query: { enabled: !!factoryAddress && !!product.contractAddress },
});
const [vaultTimedOut, setVaultTimedOut] = useState(false);
useEffect(() => {
if (!factoryAddress || !product.contractAddress) return;
setVaultTimedOut(false);
const timer = setTimeout(() => setVaultTimedOut(true), 3000);
return () => clearTimeout(timer);
}, [factoryAddress, product.contractAddress]);
const totalSupply: bigint = vaultInfo ? ((vaultInfo as any[])[4] as bigint) ?? 0n : 0n;
const hardCap: bigint = vaultInfo ? ((vaultInfo as any[])[5] as bigint) ?? 0n : 0n;
const nextRedemptionTime = vaultInfo ? Number((vaultInfo as any[])[8]) : 0;
const isMatured = nextRedemptionTime > 0 && nextRedemptionTime * 1000 < Date.now();
const isFull = hardCap > 0n && totalSupply >= hardCap;
type StatusCfg = { label: string; dot: string; bg: string; border: string; text: string };
const statusCfg: StatusCfg | null = (() => {
if (isVaultLoading && !vaultTimedOut) return null;
if (isMatured) return { label: t("product.statusEnded"), dot: "bg-gray-400", bg: "bg-gray-100 dark:bg-gray-700", border: "border-gray-300 dark:border-gray-500", text: "text-gray-500 dark:text-gray-400" };
if (isFull) return { label: t("product.statusFull"), dot: "bg-orange-500", bg: "bg-orange-50 dark:bg-orange-900/30", border: "border-orange-200 dark:border-orange-700", text: "text-orange-600 dark:text-orange-400" };
return { label: t("product.statusActive"), dot: "bg-green-500", bg: "bg-green-50 dark:bg-green-900/30", border: "border-green-200 dark:border-green-700", text: "text-green-600 dark:text-green-400" };
})();
const shortenAddress = (address: string) => {
if (!address || address.length < 10) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
};
const contractAddress = product.contractAddress || '';
const handleCopy = async () => {
if (!contractAddress) return;
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(contractAddress);
} else {
// fallback for insecure context (HTTP)
const textarea = document.createElement('textarea');
textarea.value = contractAddress;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setCopied(true);
toast.success(t("product.addressCopied") || "Contract address copied!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error(t("product.copyFailed") || "Failed to copy address");
}
};
return (
<div className="flex flex-col gap-6">
{/* Product Title Section */}
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div className="flex gap-4 md:gap-6">
<div className="flex-shrink-0">
<Image
src={product.iconUrl || '/assets/tokens/default.svg'}
alt={product.name}
width={80}
height={80}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white">
{product.name}
</h1>
{statusCfg && (
<div className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 ${statusCfg.bg} ${statusCfg.border}`}>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusCfg.dot}`} />
<span className={`text-caption-tiny font-semibold ${statusCfg.text}`}>
{statusCfg.label}
</span>
</div>
)}
</div>
<p className="text-body-default font-regular text-text-tertiary dark:text-gray-400">
{product.subtitle}
</p>
</div>
</div>
<button
onClick={handleCopy}
disabled={!contractAddress}
title={contractAddress || ''}
className="self-start"
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
borderRadius: '9999px',
border: '1px solid #CADFFF',
backgroundColor: '#EBF2FF',
color: '#1447E6',
cursor: contractAddress ? 'pointer' : 'not-allowed',
opacity: contractAddress ? 1 : 0.4,
flexShrink: 0,
}}
>
{copied ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ color: '#22c55e', flexShrink: 0 }}>
<path d="M2 7l3.5 3.5L12 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ color: '#1447E6', flexShrink: 0 }}>
<rect x="4" y="4" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.2"/>
<path d="M4 3.5V3a1.5 1.5 0 0 1 1.5-1.5H11A1.5 1.5 0 0 1 12.5 3v5.5A1.5 1.5 0 0 1 11 10h-.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
)}
<span className="text-caption-tiny font-medium font-inter">
{t("product.contractAddress")}: {shortenAddress(contractAddress) || '--'}
</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useApp } from "@/contexts/AppContext";
import { ProductDetail } from "@/lib/api/fundmarket";
interface ProtocolLinkProps {
label: string;
url?: string;
}
function ProtocolLink({ label, url }: ProtocolLinkProps) {
const enabled = !!url;
return (
<div
onClick={() => enabled && window.open(url, '_blank', 'noopener,noreferrer')}
className={`group rounded-xl border border-border-gray dark:border-gray-600 bg-bg-subtle dark:bg-gray-700 px-4 py-3.5 flex items-center justify-between w-full transition-all ${
enabled
? 'cursor-pointer hover:border-black dark:hover:border-gray-400'
: 'cursor-default opacity-40'
}`}
>
<div className="flex items-center gap-2">
<img
src="/components/product/component-17.svg"
alt=""
width={20}
height={20}
className={enabled ? "transition-all group-hover:scale-110 group-hover:brightness-0 dark:group-hover:brightness-0 dark:group-hover:invert" : ""}
/>
<span className={`text-body-small font-medium text-text-tertiary dark:text-gray-300 ${
enabled ? 'transition-all group-hover:font-bold group-hover:text-text-primary dark:group-hover:text-white' : ''
}`}>
{label}
</span>
</div>
<img
src="/components/product/component-18.svg"
alt=""
width={20}
height={20}
className={enabled ? "transition-all group-hover:scale-110 group-hover:brightness-0 dark:group-hover:brightness-0 dark:group-hover:invert" : "opacity-40"}
/>
</div>
);
}
const DEFAULT_LINKS = [
"Smart Contract",
"Compliance",
"Proof of Reserves",
"Protocol Information",
];
interface ProtocolInformationProps {
product: ProductDetail;
}
export default function ProtocolInformation({ product }: ProtocolInformationProps) {
const { t } = useApp();
const links = product.productLinks ?? [];
// 只显示 display_area 为 protocol 或 both 的链接
const protocolLinks = links.filter(
(l) => l.displayArea === 'protocol' || l.displayArea === 'both'
);
// 有配置时用配置的,没配置时用默认占位(全部禁用)
const items =
protocolLinks.length > 0
? protocolLinks.map((l) => ({ label: l.linkText, url: l.linkUrl }))
: DEFAULT_LINKS.map((label) => ({ label, url: undefined }));
return (
<div className="flex-1 bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 px-4 py-4 md:px-6 md:py-8 flex flex-col gap-4 min-h-0 overflow-hidden">
<h3 className="text-body-large font-bold text-text-primary dark:text-white">
{t("protocol.title")}
</h3>
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-y-auto pr-1 pb-1">
{items.map((item, index) => (
<ProtocolLink key={index} label={item.label} url={item.url} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
interface RewardStatProps {
label: string;
value: string;
}
function RewardStat({ label, value }: RewardStatProps) {
return (
<div className="flex flex-col items-center gap-1.5">
<span
className="text-[24px] font-bold leading-[130%] dark:text-white"
style={{ color: "#111827", letterSpacing: "-0.005em" }}
>
{value}
</span>
<span
className="text-[10px] font-bold uppercase leading-[150%] text-center dark:text-gray-400"
style={{ color: "#9ca1af", letterSpacing: "0.05em" }}
>
{label}
</span>
</div>
);
}
export default function Season1Rewards() {
const { t } = useApp();
return (
<div
className="rounded-3xl border flex flex-col relative overflow-hidden"
style={{
background:
"radial-gradient(50% 50% at 100% 0%, rgba(255, 217, 100, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%), #ffffff",
borderColor: "rgba(255, 255, 255, 0.6)",
paddingTop: "20px",
paddingBottom: "20px",
paddingLeft: "24px",
paddingRight: "24px",
}}
>
{/* Background Decoration */}
<div
className="absolute"
style={{ opacity: 0.5, right: "-15px", bottom: "-20px" }}
>
<Image
src="/components/product/component-113.svg"
alt=""
width={120}
height={144}
/>
</div>
{/* Content Container */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between relative z-10 gap-4 md:gap-8">
{/* Left: Header and Description */}
<div className="flex flex-col gap-2">
{/* Header */}
<div className="flex items-center gap-3">
<h3
className="text-[20px] font-bold leading-[140%] dark:text-white"
style={{ color: "#111827" }}
>
{t("rewards.season1")}
</h3>
<div
className="rounded-full px-2.5 py-1 flex items-center"
style={{ backgroundColor: "#111827" }}
>
<span
className="text-[10px] font-bold leading-4"
style={{ color: "#fcfcfd" }}
>
{t("rewards.live")}
</span>
</div>
</div>
{/* Description */}
<p
className="text-body-small font-medium dark:text-gray-400"
style={{ color: "#9ca1af" }}
>
{t("rewards.earnPoints")}
</p>
</div>
{/* Right: Stats */}
<div className="flex items-center justify-between md:justify-end md:gap-12">
<RewardStat label={t("rewards.yourPoints")} value="-" />
<RewardStat label={t("rewards.badgeBoost")} value="-" />
<RewardStat label={t("rewards.referrals")} value="-" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { Tabs, Tab } from "@heroui/react";
interface TabItem {
id: string;
label: string;
}
interface TabNavigationProps {
tabs: TabItem[];
defaultActiveId?: string;
onTabChange?: (tabId: string) => void;
}
export default function TabNavigation({
tabs,
defaultActiveId,
onTabChange,
}: TabNavigationProps) {
const handleSelectionChange = (key: React.Key) => {
onTabChange?.(key.toString());
};
return (
<Tabs
selectedKey={defaultActiveId || tabs[0]?.id}
onSelectionChange={handleSelectionChange}
variant="underlined"
classNames={{
base: "w-full overflow-x-auto",
tabList: "gap-6 md:gap-8 w-max md:w-auto p-0 min-w-full",
cursor: "bg-text-primary dark:bg-white",
tab: "px-0 h-auto whitespace-nowrap",
tabContent: "text-sm font-bold text-text-tertiary dark:text-gray-400 group-data-[selected=true]:text-text-primary dark:group-data-[selected=true]:text-white",
}}
>
{tabs.map((tab) => (
<Tab key={tab.id} title={tab.label} />
))}
</Tabs>
);
}

View File

@@ -0,0 +1,197 @@
"use client";
import { useApp } from "@/contexts/AppContext";
import { useEffect, useRef } from "react";
import * as echarts from "echarts";
export default function AssetDistribution() {
const { t } = useApp();
const chartRef = useRef<HTMLDivElement>(null);
const data = [
{
value: 52.7,
name: t("transparency.fixedIncome"),
itemStyle: {
color: "#1447e6",
},
},
{
value: 30.1,
name: t("transparency.alternativeAssets"),
itemStyle: {
color: "#10b981",
},
},
{
value: 17.2,
name: t("transparency.alternativeCredit"),
itemStyle: {
color: "#ff6900",
},
},
];
useEffect(() => {
if (!chartRef.current) return;
const chart = echarts.init(chartRef.current, null, {
renderer: "svg",
});
const option = {
tooltip: {
trigger: "item",
backgroundColor: "rgba(255, 255, 255, 0.95)",
borderColor: "rgba(0, 0, 0, 0.1)",
borderWidth: 1,
padding: [12, 16],
textStyle: {
color: "#0f172b",
fontSize: 13,
},
formatter: (params: any) => {
return `
<div style="font-weight: 600; margin-bottom: 4px;">${params.name}</div>
<div style="color: ${params.color}; font-weight: 700; font-size: 15px;">${params.data.value}%</div>
`;
},
},
legend: {
top: "bottom",
left: "center",
itemWidth: 12,
itemHeight: 12,
itemGap: 16,
textStyle: {
fontSize: 13,
color: "#64748b",
},
icon: "circle",
inactiveColor: "#cbd5e1",
},
series: [
{
name: "Asset Distribution",
type: "pie",
radius: ["40%", "70%"],
center: ["50%", "42%"],
avoidLabelOverlap: true,
padAngle: 6,
itemStyle: {
borderRadius: 12,
borderColor: "#fff",
borderWidth: 3,
},
label: {
show: true,
position: "outside",
alignTo: "none",
distanceToLabelLine: 5,
formatter: (params: any) => {
return `{name|${params.name}}\n{value|${params.value}%}`;
},
rich: {
name: {
fontSize: 13,
fontWeight: "600",
color: "#0f172b",
lineHeight: 18,
},
value: {
fontSize: 14,
fontWeight: "bold",
lineHeight: 20,
},
},
},
labelLine: {
show: true,
length: 20,
length2: 40,
smooth: true,
lineStyle: {
width: 1.5,
},
},
emphasis: {
scale: true,
scaleSize: 8,
itemStyle: {
shadowBlur: 20,
shadowColor: "rgba(0, 0, 0, 0.2)",
},
label: {
show: true,
formatter: (params: any) => {
return `{name|${params.name}}\n{value|${params.value}%}`;
},
fontSize: 15,
fontWeight: "bold",
color: "#0f172b",
rich: {
name: {
fontSize: 15,
fontWeight: "bold",
color: "#0f172b",
lineHeight: 22,
},
value: {
fontSize: 16,
fontWeight: "bold",
lineHeight: 24,
},
},
},
labelLine: {
show: true,
length: 20,
length2: 40,
lineStyle: {
width: 2,
},
},
},
data: data,
animationType: "expansion",
animationEasing: "cubicOut",
animationDuration: 800,
},
],
};
chart.setOption(option);
const handleResize = () => {
chart.resize();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
chart.dispose();
};
}, [t]);
return (
<div className="flex-1 bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6 flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-0">
<h3 className="text-[20px] font-bold text-text-primary dark:text-white leading-[140%]">
{t("transparency.assetDistribution")}
</h3>
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("transparency.distributionSubtitle")}
</p>
</div>
{/* Pie Chart Section */}
<div className="flex flex-col gap-3">
{/* ECharts Pie Chart */}
<div className="relative h-[320px] w-full overflow-visible">
<div ref={chartRef} className="w-full h-full" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
export default function GeographicAllocation() {
const { t } = useApp();
const regions = [
{
countryKey: "transparency.unitedStates",
regionKey: "transparency.northAmerica",
value: "$305,000,000",
percentage: "65.6%",
flag: "/assets/flags/lr0.svg",
},
{
countryKey: "transparency.hongKong",
regionKey: "transparency.asiaPacific",
value: "$160,000,000",
percentage: "34.4%",
flag: "/assets/flags/container14.svg",
},
];
return (
<div className="flex-1 bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6 flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-0">
<h3 className="text-[20px] font-bold text-text-primary dark:text-white leading-[140%]">
{t("transparency.geographicAllocation")}
</h3>
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("transparency.geographicSubtitle")}
</p>
</div>
{/* Region Cards */}
<div className="flex flex-col gap-4">
{regions.map((region, index) => (
<div
key={index}
className="bg-bg-subtle dark:bg-gray-700 rounded-2xl border border-border-gray dark:border-gray-600 p-4 flex items-center justify-between"
>
{/* Left - Flag and Location */}
<div className="flex items-center gap-3">
<div className="w-12 h-12 flex-shrink-0 flex items-center justify-center">
<Image
src={region.flag}
alt={t(region.countryKey)}
width={48}
height={48}
className="w-full h-full object-contain"
/>
</div>
<div className="flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{t(region.countryKey)}
</span>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t(region.regionKey)}
</span>
</div>
</div>
{/* Right - Value and Percentage */}
<div className="flex flex-col items-end">
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{region.value}
</span>
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{region.percentage}
</span>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
export default function HoldingsTable() {
const { t } = useApp();
const holdings = [
{
custodian: t("transparency.morganStanley"),
custodianType: t("transparency.primeBroker"),
assetType: t("transparency.usEquityPortfolio"),
maturityDate: "05 Feb 2026",
daysRemaining: `77 ${t("transparency.days")}`,
value: "$12,500,000.00",
status: t("transparency.verified"),
},
{
custodian: t("transparency.morganStanley"),
custodianType: t("transparency.primeBroker"),
assetType: t("transparency.usEquityPortfolio"),
maturityDate: "05 Feb 2026",
daysRemaining: `77 ${t("transparency.days")}`,
value: "$12,500,000.00",
status: t("transparency.verified"),
},
{
custodian: t("transparency.morganStanley"),
custodianType: t("transparency.primeBroker"),
assetType: t("transparency.usEquityPortfolio"),
maturityDate: "05 Feb 2026",
daysRemaining: `77 ${t("transparency.days")}`,
value: "$12,500,000.00",
status: t("transparency.verified"),
},
{
custodian: t("transparency.morganStanley"),
custodianType: t("transparency.primeBroker"),
assetType: t("transparency.usEquityPortfolio"),
maturityDate: "05 Feb 2026",
daysRemaining: `77 ${t("transparency.days")}`,
value: "$12,500,000.00",
status: t("transparency.verified"),
},
];
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="bg-bg-surface dark:bg-gray-800 border-b border-border-gray dark:border-gray-700 px-6 py-6 flex items-center justify-between">
<div className="flex flex-col gap-1">
<h2 className="text-[20px] font-bold text-text-primary dark:text-white leading-[140%]">
{t("transparency.rwaHoldings")}
</h2>
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("transparency.rwaSubtitle")}
</p>
</div>
<div className="flex items-center gap-2 rounded-full">
<Image src="/components/transparency/icon-clock.svg" alt="" width={16} height={16} />
<span className="text-caption-tiny font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("transparency.lastUpdated")}: 2 {t("transparency.minutesAgo")}
</span>
</div>
</div>
{/* ── 移动端:卡片布局 ── */}
<div className="md:hidden flex flex-col divide-y divide-border-gray dark:divide-gray-600">
{holdings.map((holding, index) => (
<div key={index} className="p-4 flex flex-col gap-3">
{/* Custodian 行 */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-[#ff8904] to-[#f54900]">
<span className="text-sm font-bold text-white">GY</span>
</div>
<div className="flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white">{holding.custodian}</span>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400">{holding.custodianType}</span>
</div>
{/* Status badge 右对齐 */}
<div className="ml-auto inline-flex items-center gap-[2px] bg-[#e1f8ec] dark:bg-green-900/30 border border-[#b8ecd2] dark:border-green-700 rounded-full px-3 py-1">
<Image src="/components/transparency/icon-verified.svg" alt="" width={14} height={14} />
<span className="text-[10px] font-bold text-[#10b981] dark:text-green-400 leading-[15px]">{holding.status}</span>
</div>
</div>
{/* 信息网格2列 */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-bg-subtle dark:bg-gray-700/50 rounded-xl">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 block mb-1">{t("transparency.assetType")}</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">{holding.assetType}</span>
</div>
<div className="p-3 bg-bg-subtle dark:bg-gray-700/50 rounded-xl">
<span className="text-caption-tiny font-medium text-text-tertiary dark:text-gray-400 block mb-1">{t("transparency.maturity")}</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">{holding.maturityDate}</span>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 block">({holding.daysRemaining})</span>
</div>
<div className="col-span-2 p-3 bg-bg-subtle dark:bg-gray-700/50 rounded-xl flex items-center justify-between">
<span className="text-body-small font-bold text-text-primary dark:text-white">{t("transparency.valueUsd")}</span>
<span className="text-body-small font-bold text-text-primary dark:text-white">{holding.value}</span>
</div>
</div>
</div>
))}
</div>
{/* ── 桌面端:表格布局 ── */}
<div className="hidden md:block overflow-auto">
{/* Table Header */}
<div className="flex bg-bg-subtle dark:bg-gray-700 border-b border-border-gray dark:border-gray-600">
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("transparency.custodian")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("transparency.assetType")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("transparency.maturity")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("transparency.valueUsd")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("transparency.status")}
</span>
</div>
</div>
{/* Table Body */}
{holdings.map((holding, index) => (
<div
key={index}
className={`flex items-center ${
index !== holdings.length - 1
? "border-b border-border-gray dark:border-gray-600"
: ""
}`}
>
<div className="flex-1 px-6 py-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-[#ff8904] to-[#f54900] shadow-[0px_2.01px_3.02px_-2.01px_rgba(255,105,0,0.2)]">
<span className="text-[10px] font-bold text-white leading-[14px] tracking-[-0.23px]">GY</span>
</div>
<div className="flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">{holding.custodian}</span>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">{holding.custodianType}</span>
</div>
</div>
<div className="flex-1 px-6 py-6">
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">{holding.assetType}</span>
</div>
<div className="flex-1 px-6 py-6 flex flex-col">
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">{holding.maturityDate}</span>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">({holding.daysRemaining})</span>
</div>
<div className="flex-1 px-6 py-6">
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">{holding.value}</span>
</div>
<div className="flex-1 px-6 py-6">
<div className="inline-flex items-center gap-[2px] bg-[#e1f8ec] dark:bg-green-900/30 border border-[#b8ecd2] dark:border-green-700 rounded-full px-3 py-1">
<Image src="/components/transparency/icon-verified.svg" alt="" width={14} height={14} />
<span className="text-[10px] font-bold text-[#10b981] dark:text-green-400 leading-[15px]">{holding.status}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { useApp } from "@/contexts/AppContext";
interface CircleProgressProps {
percentage: number;
color: string;
percentageText: string;
}
function CircleProgress({ percentage, color, percentageText }: CircleProgressProps) {
const size = 50;
const strokeWidth = 4;
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (percentage / 100) * circumference;
return (
<div className="relative flex items-center justify-center flex-shrink-0" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
{/* 背景圆环 - 浅色轨道 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="transparent"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-slate-200/30 dark:text-gray-600"
/>
{/* 进度圆环 - 填充部分 */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="transparent"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
style={{
strokeDashoffset: offset,
transition: 'stroke-dashoffset 1s ease-in-out',
strokeLinecap: 'round'
}}
/>
</svg>
{/* 中间的百分比文字 */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{percentageText}
</span>
</div>
</div>
);
}
export default function TransparencyStats() {
const { t } = useApp();
const stats = [
{
labelKey: "transparency.totalUsdcSupply",
value: "$200.4M",
percentage: 52.7,
percentageText: "52.7%",
percentageColor: "text-[#1447e6]",
circleColor: "#1447e6",
},
{
labelKey: "transparency.utilization",
value: "$140.0M",
percentage: 30.1,
percentageText: "30.1%",
percentageColor: "text-[#ff6900]",
circleColor: "#ff6900",
},
{
labelKey: "transparency.activeLoans",
value: "$80.0M",
percentage: 17.2,
percentageText: "17.2%",
percentageColor: "text-[#10b981]",
circleColor: "#10b981",
},
];
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-6 flex flex-col gap-6">
{/* Total Reserves */}
<div className="flex flex-col gap-2">
<span className="text-caption-tiny font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("transparency.totalReserves")}
</span>
<span className="text-heading-h2 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.01em]">
$465,000,000
</span>
</div>
{/* Stats Cards */}
<div className="flex flex-col md:flex-row gap-4">
{stats.map((stat, index) => (
<div
key={index}
className="flex-1 bg-bg-subtle dark:bg-gray-700 rounded-2xl border border-border-gray dark:border-gray-600 p-4 flex items-center justify-between"
>
{/* Left - Text Info */}
<div className="flex flex-col gap-2">
{/* Label */}
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t(stat.labelKey)}
</span>
{/* Value */}
<span className="text-[20px] font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{stat.value}
</span>
{/* Percentage */}
<span className={`text-[12px] font-medium leading-[150%] tracking-[0.01em] ${stat.percentageColor}`}>
{stat.percentageText}
</span>
</div>
{/* Right - Circle Chart */}
<CircleProgress
percentage={stat.percentage}
color={stat.circleColor}
percentageText={stat.percentageText}
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
"use client";
interface WalletButtonProps {
compact?: boolean;
}
export default function WalletButton({ compact }: WalletButtonProps) {
return <appkit-button size={compact ? 'sm' : 'md'} />;
}

View File

@@ -0,0 +1,123 @@
"use client";
import { createContext, useContext, useState, useEffect, useMemo, useCallback, ReactNode } from "react";
type Language = "zh" | "en";
type Theme = "light" | "dark";
const VALID_LANGUAGES: Language[] = ["zh", "en"];
const VALID_THEMES: Theme[] = ["light", "dark"];
function isValidLanguage(val: unknown): val is Language {
return typeof val === 'string' && VALID_LANGUAGES.includes(val as Language);
}
function isValidTheme(val: unknown): val is Theme {
return typeof val === 'string' && VALID_THEMES.includes(val as Theme);
}
interface AppContextType {
language: Language;
setLanguage: (lang: Language) => void;
theme: Theme;
setTheme: (theme: Theme) => void;
t: (key: string) => string;
}
const AppContext = createContext<AppContextType | undefined>(undefined);
// 模块级翻译缓存
const translationCache: Record<string, Record<string, unknown>> = {};
function loadTranslations(lang: Language): Record<string, unknown> {
if (!translationCache[lang]) {
translationCache[lang] = require(`../locales/${lang}.json`);
}
return translationCache[lang];
}
export function AppProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>("en");
const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
// SSR 守卫:仅 mounted 后读取 localStorage/matchMedia
useEffect(() => {
setMounted(true);
try {
const savedLanguage = localStorage.getItem("language");
if (isValidLanguage(savedLanguage)) {
setLanguageState(savedLanguage);
}
// 强制 light 主题
localStorage.setItem("theme", "light");
} catch {
// 隐私模式或 localStorage 不可用
}
}, []);
// Apply theme
useEffect(() => {
if (!mounted) return;
try {
localStorage.setItem("theme", theme);
} catch {
// 隐私模式兼容
}
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}, [theme, mounted]);
// Save language preference
const setLanguage = useCallback((lang: Language) => {
setLanguageState(lang);
try {
localStorage.setItem("language", lang);
} catch {
// 隐私模式兼容
}
}, []);
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
}, []);
// Translation function — 使用模块级缓存
const t = useCallback((key: string): string => {
const translations = loadTranslations(language);
const keys = key.split(".");
let value: unknown = translations;
for (const k of keys) {
if (typeof value === 'object' && value !== null) {
value = (value as Record<string, unknown>)[k];
} else {
return key;
}
}
return typeof value === 'string' ? value : key;
}, [language]);
const contextValue = useMemo(
() => ({ language, setLanguage, theme, setTheme, t }),
[language, setLanguage, theme, setTheme, t]
);
return (
<AppContext.Provider value={contextValue}>
{children}
</AppContext.Provider>
);
}
export function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error("useApp must be used within AppProvider");
}
return context;
}

View File

@@ -0,0 +1,20 @@
module.exports = {
apps: [{
name: 'back',
script: 'bun',
args: 'x next dev -H 0.0.0.0 -p 3010',
cwd: './',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'development',
PORT: 3010
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_file: './logs/combined.log',
time: true
}]
}

View File

@@ -0,0 +1,21 @@
module.exports = {
apps: [{
name: 'back',
script: 'bun',
args: 'x next start -H 0.0.0.0 -p 3010',
cwd: './',
instances: 1,
exec_mode: 'cluster',
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 3010
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_file: './logs/combined.log',
time: true
}]
}

127
webapp/hooks/useBalance.ts Normal file
View File

@@ -0,0 +1,127 @@
import { useAccount, useReadContract } from 'wagmi'
import { formatUnits } from 'viem'
import { abis, getContractAddress } from '@/lib/contracts'
import { useTokenList } from './useTokenList'
/**
* 查询 USDC 余额
*/
export function useUSDCBalance() {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const usdcToken = bySymbol['USDC']
const contractAddress = chainId ? getContractAddress('USDC', chainId) : undefined
const { data: balance, isLoading, error, refetch } = useReadContract({
address: contractAddress,
abi: abis.USDY,
functionName: 'balanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!chainId && !!contractAddress,
refetchInterval: 10000,
}
})
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
const formattedBalance = balance ? formatUnits(balance as bigint, usdcDecimals) : '0'
return {
balance: balance as bigint | undefined,
formattedBalance,
isLoading,
error,
refetch,
}
}
/**
* 查询 YT LP Token 余额
*/
export function useYTLPBalance() {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const lpToken = bySymbol['YTLPToken'] ?? bySymbol['LP']
const contractAddress = chainId ? getContractAddress('YTLPToken', chainId) : undefined
const { data: balance, isLoading, error, refetch } = useReadContract({
address: contractAddress,
abi: abis.YTLPToken,
functionName: 'balanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!chainId && !!contractAddress,
refetchInterval: 10000,
}
})
const lpDecimals = lpToken?.onChainDecimals ?? lpToken?.decimals ?? 18
const formattedBalance = balance ? formatUnits(balance as bigint, lpDecimals) : '0'
return {
balance: balance as bigint | undefined,
formattedBalance,
isLoading,
error,
refetch,
}
}
/**
* 通用 ERC20 余额查询(按合约地址)
*/
export function useTokenBalance(contractAddress: string | undefined, decimals: number = 18) {
const { address } = useAccount()
const { data: balance, isLoading, error, refetch } = useReadContract({
address: contractAddress as `0x${string}` | undefined,
abi: abis.YTToken,
functionName: 'balanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!contractAddress,
refetchInterval: 10000,
}
})
const formattedBalance = balance ? formatUnits(balance as bigint, decimals) : '0'
return {
balance: balance as bigint | undefined,
formattedBalance,
isLoading,
error,
refetch,
}
}
/**
* 查询 USDC 授权额度
*/
export function useUSDCAllowance(spenderAddress?: `0x${string}`) {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const usdcToken = bySymbol['USDC']
const contractAddress = chainId ? getContractAddress('USDC', chainId) : undefined
const { data: allowance, isLoading, error, refetch } = useReadContract({
address: contractAddress,
abi: abis.USDY,
functionName: 'allowance',
args: address && spenderAddress ? [address, spenderAddress] : undefined,
query: {
enabled: !!address && !!chainId && !!spenderAddress && !!contractAddress,
}
})
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
const formattedAllowance = allowance ? formatUnits(allowance as bigint, usdcDecimals) : '0'
return {
allowance: allowance as bigint | undefined,
formattedAllowance,
isLoading,
error,
refetch,
}
}

View File

@@ -0,0 +1,267 @@
import { useAccount, useReadContract, useReadContracts } from 'wagmi'
import { formatUnits } from 'viem'
import { abis, getContractAddress } from '@/lib/contracts'
import { Token } from '@/lib/api/tokens'
import { useTokenList } from './useTokenList'
/**
* Hook to query user's collateral balance for a specific YT token
*/
export function useCollateralBalance(token: Token | undefined) {
const { address, chainId } = useAccount()
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const { data: collateralBalance, refetch, isLoading } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getCollateral',
args: address && token?.contractAddress ? [address, token.contractAddress as `0x${string}`] : undefined,
query: {
enabled: !!address && !!token?.contractAddress && !!lendingProxyAddress,
},
})
const decimals = token?.onChainDecimals ?? token?.decimals ?? 18
const formattedBalance = collateralBalance
? formatUnits(collateralBalance as bigint, decimals)
: '0'
return {
balance: collateralBalance as bigint | undefined,
formattedBalance,
isLoading,
refetch,
}
}
/**
* Hook to query user's borrow balance (in USDC)
*/
export function useBorrowBalance() {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const usdcToken = bySymbol['USDC']
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const { data: borrowBalance, refetch, isLoading } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'borrowBalanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!chainId && !!lendingProxyAddress,
},
})
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
const formattedBalance = borrowBalance
? formatUnits(borrowBalance as bigint, usdcDecimals)
: '0.00'
return {
balance: borrowBalance as bigint | undefined,
formattedBalance,
isLoading,
refetch,
}
}
/**
* Hook to query user's balance (positive = supply, negative = borrow)
*/
export function useAccountBalance() {
const { address, chainId } = useAccount()
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const { data: supplyBalance, refetch: refetchSupply, isLoading: isLoadingSupply } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'supplyBalanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!chainId && !!lendingProxyAddress,
},
})
const { data: borrowBalance, refetch: refetchBorrow, isLoading: isLoadingBorrow } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'borrowBalanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!chainId && !!lendingProxyAddress,
},
})
const supplyValue = (supplyBalance as bigint) || 0n
const borrowValue = (borrowBalance as bigint) || 0n
const netBalance = supplyValue - borrowValue
return {
balance: netBalance,
isSupply: supplyValue > 0n,
isBorrow: borrowValue > 0n,
isLoading: isLoadingSupply || isLoadingBorrow,
refetch: () => {
refetchSupply()
refetchBorrow()
},
}
}
/**
* Hook to calculate LTV (Loan-to-Value) ratio
* LTV = (Borrow Balance / Collateral Value) * 100
*/
export function useLTV() {
const { bySymbol } = useTokenList()
const ytA = bySymbol['YT-A']
const ytB = bySymbol['YT-B']
const ytC = bySymbol['YT-C']
const { formattedBalance: borrowBalance } = useBorrowBalance()
const { value: valueA } = useCollateralValue(ytA)
const { value: valueB } = useCollateralValue(ytB)
const { value: valueC } = useCollateralValue(ytC)
const totalCollateralValue = parseFloat(valueA) + parseFloat(valueB) + parseFloat(valueC)
const borrowValue = parseFloat(borrowBalance)
const ltv = totalCollateralValue > 0
? (borrowValue / totalCollateralValue) * 100
: 0
return {
ltv: ltv.toFixed(2),
ltvRaw: ltv,
isLoading: false,
}
}
/**
* Hook to get YT token price from its contract
*/
export function useYTPrice(token: Token | undefined) {
const { data: ytPrice, isLoading } = useReadContract({
address: token?.contractAddress as `0x${string}` | undefined,
abi: abis.YTToken,
functionName: 'ytPrice',
query: {
enabled: !!token?.contractAddress,
},
})
// ytPrice 返回 30 位精度的价格PRICE_PRECISION = 1e30
const formattedPrice = ytPrice
? formatUnits(ytPrice as bigint, 30)
: '0'
return {
price: ytPrice as bigint | undefined,
formattedPrice,
isLoading,
}
}
/**
* Hook to calculate collateral value in USD
*/
export function useCollateralValue(token: Token | undefined) {
const { formattedBalance } = useCollateralBalance(token)
const { formattedPrice } = useYTPrice(token)
const value = parseFloat(formattedBalance) * parseFloat(formattedPrice)
return {
value: value.toFixed(2),
valueRaw: value,
}
}
type AssetConfig = {
asset: `0x${string}`
decimals: number
borrowCollateralFactor: bigint
liquidateCollateralFactor: bigint
liquidationFactor: bigint
supplyCap: bigint
}
/**
* Hook to calculate total borrow capacity and available borrowable amount
*/
export function useMaxBorrowable() {
const { chainId } = useAccount()
const { bySymbol } = useTokenList()
const ytA = bySymbol['YT-A']
const ytB = bySymbol['YT-B']
const ytC = bySymbol['YT-C']
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
// Collateral balances
const { formattedBalance: balA, refetch: refetchA } = useCollateralBalance(ytA)
const { formattedBalance: balB, refetch: refetchB } = useCollateralBalance(ytB)
const { formattedBalance: balC, refetch: refetchC } = useCollateralBalance(ytC)
// YT token prices (ytPrice(), 30-decimal precision)
const { formattedPrice: priceA } = useYTPrice(ytA)
const { formattedPrice: priceB } = useYTPrice(ytB)
const { formattedPrice: priceC } = useYTPrice(ytC)
// Borrow collateral factors from lendingProxy.assetConfigs
const ytAddresses = [ytA, ytB, ytC]
.filter(t => !!t?.contractAddress)
.map(t => t!.contractAddress as `0x${string}`)
const configContracts = ytAddresses.map((addr) => ({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'assetConfigs' as const,
args: [addr],
}))
const { data: configData } = useReadContracts({
contracts: configContracts as any,
query: { enabled: !!lendingProxyAddress && configContracts.length > 0 },
})
type ConfigTuple = readonly [string, number, bigint, bigint, bigint, bigint]
const getConfig = (i: number): ConfigTuple | undefined =>
configData?.[i]?.result as ConfigTuple | undefined
const calcCapacity = (bal: string, price: string, config: ConfigTuple | undefined) => {
if (!config || !config[2]) return 0
const factor = Number(config[2]) / 1e18
return parseFloat(bal) * parseFloat(price) * factor
}
const capacityA = calcCapacity(balA, priceA, getConfig(0))
const capacityB = calcCapacity(balB, priceB, getConfig(1))
const capacityC = calcCapacity(balC, priceC, getConfig(2))
const totalCapacity = capacityA + capacityB + capacityC
const { formattedBalance: borrowedBalance, refetch: refetchBorrow } = useBorrowBalance()
const currentBorrow = parseFloat(borrowedBalance) || 0
const available = Math.max(0, totalCapacity - currentBorrow)
const refetch = () => {
refetchA()
refetchB()
refetchC()
refetchBorrow()
}
return {
totalCapacity,
available,
formattedCapacity: totalCapacity.toFixed(2),
formattedAvailable: available.toFixed(2),
refetch,
}
}

View File

@@ -0,0 +1,27 @@
'use client'
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchContracts } from '@/lib/api/contracts'
import { setContractAddressDynamic } from '@/lib/contracts/registry'
/**
* Fetches contract addresses from the backend and populates the dynamic registry.
* Call once near the app root (e.g., inside Providers or AppContext).
* All existing getContractAddress() callers automatically see updated values.
*/
export function useContractRegistry() {
const { data } = useQuery({
queryKey: ['contract-registry'],
queryFn: fetchContracts,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
useEffect(() => {
if (!data) return
for (const c of data) {
if (c.address) setContractAddressDynamic(c.name, c.chain_id, c.address)
}
}, [data])
}

167
webapp/hooks/useDeposit.ts Normal file
View File

@@ -0,0 +1,167 @@
import { useState, useCallback, useEffect } from 'react'
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { parseUnits } from 'viem'
import { abis } from '@/lib/contracts'
import { Token } from '@/lib/api/tokens'
import { useTokenList } from './useTokenList'
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
import { DEFAULT_GAS_LIMIT } from '@/lib/constants'
type DepositStatus = 'idle' | 'approving' | 'approved' | 'depositing' | 'success' | 'error'
export function useDeposit(ytToken: Token | undefined) {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const usdcToken = bySymbol['USDC']
const [status, setStatus] = useState<DepositStatus>('idle')
const [error, setError] = useState<string | null>(null)
const [pendingAmount, setPendingAmount] = useState<string>('')
const {
writeContractAsync: approveWrite,
data: approveHash,
isPending: isApprovePending,
reset: resetApprove,
} = useWriteContract()
const {
isLoading: isApproveConfirming,
isSuccess: isApproveSuccess,
isError: isApproveError,
error: approveReceiptError,
} = useWaitForTransactionReceipt({ hash: approveHash })
const {
writeContractAsync: depositWrite,
data: depositHash,
isPending: isDepositPending,
reset: resetDeposit,
} = useWriteContract()
const {
isLoading: isDepositConfirming,
isSuccess: isDepositSuccess,
isError: isDepositError,
error: depositReceiptError,
} = useWaitForTransactionReceipt({ hash: depositHash })
const executeApprove = useCallback(async (amount: string) => {
if (status !== 'idle') return false
if (!address || !ytToken?.contractAddress || !usdcToken?.contractAddress || !amount) {
setError('Missing required parameters')
return false
}
if (!isValidAmount(amount)) {
setError('Invalid amount')
return false
}
try {
setError(null)
setStatus('approving')
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
const amountInWei = parseUnits(amount, usdcDecimals)
await approveWrite({
address: usdcToken.contractAddress as `0x${string}`,
abi: abis.USDY,
functionName: 'approve',
args: [ytToken.contractAddress as `0x${string}`, amountInWei],
})
return true
} catch (err: unknown) {
handleContractCatchError(err, 'Approve failed', setError, setStatus as (s: string) => void)
return false
}
}, [address, approveWrite, ytToken, usdcToken, status])
const executeDeposit = useCallback(async (amount: string) => {
if (status === 'depositing' || status === 'success' || status === 'error') return false
if (!address || !ytToken?.contractAddress || !usdcToken || !amount) {
setError('Missing required parameters')
return false
}
if (!isValidAmount(amount)) {
setError('Invalid amount')
return false
}
try {
setError(null)
setStatus('depositing')
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
const amountInWei = parseUnits(amount, usdcDecimals)
await depositWrite({
address: ytToken.contractAddress as `0x${string}`,
abi: abis.YTToken,
functionName: 'depositYT',
args: [amountInWei],
gas: DEFAULT_GAS_LIMIT,
})
return true
} catch (err: unknown) {
handleContractCatchError(err, 'Deposit failed', setError, setStatus as (s: string) => void)
return false
}
}, [address, depositWrite, ytToken, usdcToken, status])
const executeApproveAndDeposit = useCallback(async (amount: string) => {
if (status !== 'idle') return
setPendingAmount(amount)
const approveSuccess = await executeApprove(amount)
if (!approveSuccess) return
}, [executeApprove, status])
// Auto-execute deposit when approve is successful
useEffect(() => {
if (isApproveSuccess && status === 'approving' && pendingAmount) {
setStatus('approved')
executeDeposit(pendingAmount)
}
}, [isApproveSuccess, status, pendingAmount, executeDeposit])
useEffect(() => {
if (isApproveError && status === 'approving') {
setError(parseContractError(approveReceiptError))
setStatus('error')
}
}, [isApproveError, status, approveReceiptError])
useEffect(() => {
if (isDepositSuccess && status === 'depositing') {
setStatus('success')
}
}, [isDepositSuccess, status])
useEffect(() => {
if (isDepositError && status === 'depositing') {
setError(parseContractError(depositReceiptError))
setStatus('error')
}
}, [isDepositError, status, depositReceiptError])
const reset = useCallback(() => {
setStatus('idle')
setError(null)
setPendingAmount('')
resetApprove()
resetDeposit()
}, [resetApprove, resetDeposit])
return {
status,
error,
isLoading: isApprovePending || isApproveConfirming || isDepositPending || isDepositConfirming,
approveHash,
depositHash,
executeApprove,
executeDeposit,
executeApproveAndDeposit,
reset,
}
}

View File

@@ -0,0 +1,450 @@
import { useAccount, useReadContract, useReadContracts } from 'wagmi'
import { formatUnits } from 'viem'
import { abis, getContractAddress } from '@/lib/contracts'
import { useEffect, useState } from 'react'
import { Token } from '@/lib/api/tokens'
import { useTokenList } from './useTokenList'
/**
* 健康因子状态
*/
export type HealthFactorStatus = 'safe' | 'warning' | 'danger' | 'critical'
/**
* 健康因子结果
*/
export interface HealthFactorResult {
healthFactor: bigint
formattedHealthFactor: string
status: HealthFactorStatus
utilization: number // 利用率百分比
borrowValue: bigint // 借款价值USD1e18精度
collateralValue: bigint // 抵押品价值USD1e18精度
isLoading: boolean
refetch: () => void
}
/**
* 获取用户健康因子
*
* 健康因子定义:
* - > 1.5: 安全(绿色)
* - 1.2 ~ 1.5: 警告(黄色)
* - 1.0 ~ 1.2: 危险(橙色)
* - < 1.0: 临界/可被清算(红色)
* - MaxUint256: 无债务
*/
export function useHealthFactor(): HealthFactorResult {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const usdcToken = bySymbol['USDC']
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
// 1. 获取用户基本信息principal
const { data: userBasic, refetch: refetchUserBasic } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'userBasic',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!lendingProxyAddress,
},
})
const principal = userBasic !== undefined ? (userBasic as bigint) : 0n
// 如果没有借款principal >= 0提前返回
const hasDebt = principal < 0n
const shouldCalculate = hasDebt && !!address && !!lendingProxyAddress
// 2. 获取借款索引
const { data: borrowIndex, refetch: refetchBorrowIndex } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'borrowIndex',
query: {
enabled: shouldCalculate,
},
})
// 3. 获取价格预言机地址
const { data: priceFeedAddress } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'lendingPriceSource',
query: {
enabled: shouldCalculate,
},
})
// 4. 获取 baseToken 地址
const { data: baseToken } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'baseToken',
query: {
enabled: shouldCalculate,
},
})
// 5. 直接获取资产列表YT-A, YT-B, YT-C
const assetIndices = [0, 1, 2] as const
const assetListContracts = assetIndices.map((i) => ({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'assetList' as const,
args: [BigInt(i)],
}))
const { data: assetListData } = useReadContracts({
contracts: assetListContracts as any,
query: {
enabled: shouldCalculate && assetListContracts.length > 0,
},
})
const assetList = assetListData?.map((item) => item.result as `0x${string}`).filter(Boolean) || []
// 7. 使用 useState 来管理计算结果
const [result, setResult] = useState<HealthFactorResult>({
healthFactor: BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), // MaxUint256
formattedHealthFactor: '∞',
status: 'safe',
utilization: 0,
borrowValue: 0n,
collateralValue: 0n,
isLoading: true,
refetch: () => {
refetchUserBasic()
refetchBorrowIndex()
refetchCollateral()
refetchPrices()
},
})
// 8. 获取所有抵押品余额和配置
const collateralContracts = assetList.flatMap((asset) => [
// 获取用户抵押品余额
{
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'userCollateral' as const,
args: [address, asset],
},
// 获取资产配置
{
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'assetConfigs' as const,
args: [asset],
},
])
const { data: collateralData, refetch: refetchCollateral } = useReadContracts({
contracts: collateralContracts as any,
query: {
enabled: shouldCalculate && assetList.length > 0,
},
})
// 9. 获取所有资产价格(包括 baseToken
const priceContracts = [baseToken, ...assetList].filter(Boolean).map((token) => ({
address: priceFeedAddress,
abi: [
{
type: 'function',
name: 'getPrice',
inputs: [{ name: 'asset', type: 'address' }],
outputs: [{ name: '', type: 'uint256' }],
stateMutability: 'view',
},
],
functionName: 'getPrice' as const,
args: [token],
}))
const { data: priceData, refetch: refetchPrices } = useReadContracts({
contracts: priceContracts as any,
query: {
enabled: shouldCalculate && !!priceFeedAddress && priceContracts.length > 0,
},
})
// 10. 计算健康因子
useEffect(() => {
if (!shouldCalculate) {
// 没有债务,返回最大健康因子
setResult({
healthFactor: BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
formattedHealthFactor: '∞',
status: 'safe',
utilization: 0,
borrowValue: 0n,
collateralValue: 0n,
isLoading: false,
refetch: () => {
refetchUserBasic()
},
})
return
}
if (!borrowIndex || !priceData || !collateralData || !baseToken || !chainId) {
return
}
try {
// 计算实际债务principal * borrowIndex / 1e18
const balance = (principal * (borrowIndex as bigint)) / BigInt(1e18)
const debt = -balance // 转为正数
// 获取 baseToken 价格(第一个价格)
const basePrice = priceData[0]?.result as bigint
if (!basePrice) return
// 计算债务价值USD
const baseDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
const debtValue = (debt * basePrice) / BigInt(10 ** baseDecimals)
if (debtValue === 0n) {
setResult({
healthFactor: BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
formattedHealthFactor: '∞',
status: 'safe',
utilization: 0,
borrowValue: 0n,
collateralValue: 0n,
isLoading: false,
refetch: () => {
refetchUserBasic()
},
})
return
}
// 计算抵押品总价值(使用 liquidateCollateralFactor
let totalCollateralValue = 0n
// assetConfigs 返回元组: [asset, decimals, borrowCollateralFactor, liquidateCollateralFactor, liquidationFactor, supplyCap]
type AssetConfigTuple = readonly [string, number, bigint, bigint, bigint, bigint]
for (let i = 0; i < assetList.length; i++) {
const collateralAmount = collateralData[i * 2]?.result as bigint
const assetConfig = collateralData[i * 2 + 1]?.result as AssetConfigTuple | undefined
const assetPrice = priceData[i + 1]?.result as bigint
if (collateralAmount > 0n && assetConfig && assetPrice) {
// assetConfig[1] = decimals, assetConfig[3] = liquidateCollateralFactor
const assetScale = 10n ** BigInt(Number(assetConfig[1]))
const collateralValue = (collateralAmount * assetPrice) / assetScale
// 应用清算折扣系数liquidateCollateralFactor
const discountedValue = (collateralValue * assetConfig[3]) / BigInt(1e18)
totalCollateralValue += discountedValue
}
}
// 计算健康因子 = 抵押品价值 / 债务价值1e18 精度)
const healthFactor = (totalCollateralValue * BigInt(1e18)) / debtValue
// 计算利用率(债务/抵押品)
const utilization = totalCollateralValue > 0n
? Number((debtValue * BigInt(10000)) / totalCollateralValue) / 100
: 0
// 确定健康因子状态
let status: HealthFactorStatus
const hfNumber = Number(formatUnits(healthFactor, 18))
if (hfNumber >= 1.5) {
status = 'safe'
} else if (hfNumber >= 1.2) {
status = 'warning'
} else if (hfNumber >= 1.0) {
status = 'danger'
} else {
status = 'critical'
}
// 格式化健康因子显示
const formattedHealthFactor = hfNumber >= 10
? hfNumber.toFixed(1)
: hfNumber >= 1
? hfNumber.toFixed(2)
: hfNumber.toFixed(3)
setResult({
healthFactor,
formattedHealthFactor,
status,
utilization,
borrowValue: debtValue,
collateralValue: totalCollateralValue,
isLoading: false,
refetch: () => {
refetchUserBasic()
refetchBorrowIndex()
refetchCollateral()
refetchPrices()
},
})
} catch (error) {
console.error('Health factor calculation error:', error)
setResult((prev) => ({ ...prev, isLoading: false }))
}
}, [
shouldCalculate,
principal,
borrowIndex,
priceData,
collateralData,
baseToken,
chainId,
assetList.length,
refetchUserBasic,
])
return result
}
/**
* 获取用户借款余额
*/
export function useBorrowBalance() {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const usdcToken = bySymbol['USDC']
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const { data: borrowBalance, refetch } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'borrowBalanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!lendingProxyAddress,
},
})
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
const formattedBalance = borrowBalance ? formatUnits(borrowBalance as bigint, usdcDecimals) : '0.00'
return {
balance: borrowBalance as bigint | undefined,
formattedBalance,
refetch,
}
}
/**
* 获取用户在特定资产的抵押品余额
*/
export function useCollateralBalance(token: Token | undefined) {
const { address, chainId } = useAccount()
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const { data: collateralBalance, refetch } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'userCollateral',
args: address && token?.contractAddress ? [address, token.contractAddress as `0x${string}`] : undefined,
query: {
enabled: !!address && !!lendingProxyAddress && !!token?.contractAddress,
},
})
const decimals = token?.onChainDecimals ?? token?.decimals ?? 18
const formattedBalance = collateralBalance ? formatUnits(collateralBalance as bigint, decimals) : '0.00'
return {
balance: collateralBalance as bigint | undefined,
formattedBalance,
refetch,
}
}
/**
* 获取协议利用率
*/
export function useProtocolUtilization() {
const { chainId } = useAccount()
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const { data: utilization } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getUtilization',
query: {
enabled: !!lendingProxyAddress,
},
})
// utilization 是 1e18 精度的百分比
const utilizationPercent = utilization ? Number(formatUnits(utilization as bigint, 18)) * 100 : 0
return {
utilization: utilization as bigint | undefined,
utilizationPercent,
}
}
const SECONDS_PER_YEAR = 365 * 24 * 60 * 60
function calcAPY(annualAprBigint: bigint | undefined): { apr: number; apy: number } {
if (!annualAprBigint) return { apr: 0, apy: 0 }
// getSupplyRate / getBorrowRate return annual APR with 1e18 precision
const annualApr = Number(formatUnits(annualAprBigint, 18)) // e.g. 0.05 = 5%
const perSecondRate = annualApr / SECONDS_PER_YEAR
const apy = perSecondRate > 0
? (Math.pow(1 + perSecondRate, SECONDS_PER_YEAR) - 1) * 100
: 0
return { apr: annualApr * 100, apy }
}
/**
* 获取当前供应 APY
* getSupplyRate() 返回年化 APR1e18 精度uint64
*/
export function useSupplyAPY() {
const { chainId } = useAccount()
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const { data: supplyRate } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getSupplyRate',
query: { enabled: !!lendingProxyAddress },
})
const { apr, apy } = calcAPY(supplyRate as bigint | undefined)
return {
supplyRate: supplyRate as bigint | undefined,
apr,
apy,
}
}
/**
* 获取当前借款 APR/APY
* getBorrowRate() 返回年化 APR1e18 精度uint64
*/
export function useBorrowAPR() {
const { chainId } = useAccount()
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const { data: borrowRate } = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'getBorrowRate',
query: { enabled: !!lendingProxyAddress },
})
const { apr, apy } = calcAPY(borrowRate as bigint | undefined)
return {
borrowRate: borrowRate as bigint | undefined,
apr,
apy,
}
}

View File

@@ -0,0 +1,329 @@
import { useState, useCallback, useEffect } from 'react'
import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract } from 'wagmi'
import { parseUnits, formatUnits } from 'viem'
import { abis, getContractAddress } from '@/lib/contracts'
import { Token } from '@/lib/api/tokens'
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
type WithdrawCollateralStatus = 'idle' | 'withdrawing' | 'success' | 'error'
/**
* Hook for withdrawing collateral (YT tokens) from lending protocol
*/
export function useWithdrawCollateral() {
const { address, chainId } = useAccount()
const [status, setStatus] = useState<WithdrawCollateralStatus>('idle')
const [error, setError] = useState<string | null>(null)
const {
writeContractAsync: withdrawWrite,
data: withdrawHash,
isPending: isWithdrawPending,
reset: resetWithdraw,
} = useWriteContract()
const {
isLoading: isWithdrawConfirming,
isSuccess: isWithdrawSuccess,
isError: isWithdrawError,
error: withdrawReceiptError,
} = useWaitForTransactionReceipt({ hash: withdrawHash })
const executeWithdrawCollateral = useCallback(async (token: Token, amount: string) => {
if (status !== 'idle') return false
if (!address || !chainId || !token?.contractAddress || !amount) {
setError('Missing required parameters')
return false
}
if (!isValidAmount(amount)) {
setError('Invalid amount')
return false
}
try {
setError(null)
setStatus('withdrawing')
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
if (!lendingProxyAddress) {
throw new Error('Contract not deployed on this chain')
}
const decimals = token.onChainDecimals ?? token.decimals
const amountInWei = parseUnits(amount, decimals)
await withdrawWrite({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'withdrawCollateral',
args: [token.contractAddress as `0x${string}`, amountInWei],
})
return true
} catch (err: unknown) {
handleContractCatchError(err, 'Withdraw collateral failed', setError, setStatus as (s: string) => void)
return false
}
}, [address, chainId, withdrawWrite, status])
useEffect(() => {
if (isWithdrawSuccess && status === 'withdrawing') {
setStatus('success')
}
}, [isWithdrawSuccess, status])
useEffect(() => {
if (isWithdrawError && status === 'withdrawing') {
setError(parseContractError(withdrawReceiptError))
setStatus('error')
}
}, [isWithdrawError, status, withdrawReceiptError])
const reset = useCallback(() => {
setStatus('idle')
setError(null)
resetWithdraw()
}, [resetWithdraw])
return {
status,
error,
isLoading: isWithdrawPending || isWithdrawConfirming,
withdrawHash,
executeWithdrawCollateral,
reset,
}
}
type CollateralStatus = 'idle' | 'approving' | 'approved' | 'supplying' | 'success' | 'error'
/**
* Hook for supplying collateral (YT tokens) to lending protocol
*/
export function useLendingCollateral() {
const { address, chainId } = useAccount()
const [status, setStatus] = useState<CollateralStatus>('idle')
const [error, setError] = useState<string | null>(null)
const [pendingToken, setPendingToken] = useState<Token | null>(null)
const [pendingAmount, setPendingAmount] = useState<string>('')
const {
writeContractAsync: approveWrite,
data: approveHash,
isPending: isApprovePending,
reset: resetApprove,
} = useWriteContract()
const {
isLoading: isApproveConfirming,
isSuccess: isApproveSuccess,
isError: isApproveError,
error: approveReceiptError,
} = useWaitForTransactionReceipt({ hash: approveHash })
const {
writeContractAsync: supplyWrite,
data: supplyHash,
isPending: isSupplyPending,
reset: resetSupply,
} = useWriteContract()
const {
isLoading: isSupplyConfirming,
isSuccess: isSupplySuccess,
isError: isSupplyError,
error: supplyReceiptError,
} = useWaitForTransactionReceipt({ hash: supplyHash })
const executeApprove = useCallback(async (token: Token, amount: string) => {
if (status !== 'idle') return false
if (!address || !chainId || !token?.contractAddress || !amount) {
setError('Missing required parameters')
return false
}
if (!isValidAmount(amount)) {
setError('Invalid amount')
return false
}
try {
setError(null)
setStatus('approving')
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
if (!lendingProxyAddress) {
throw new Error('Contract not deployed on this chain')
}
const decimals = token.onChainDecimals ?? token.decimals
const amountInWei = parseUnits(amount, decimals)
await approveWrite({
address: token.contractAddress as `0x${string}`,
abi: abis.YTToken,
functionName: 'approve',
args: [lendingProxyAddress, amountInWei],
})
return true
} catch (err: unknown) {
handleContractCatchError(err, 'Approve failed', setError, setStatus as (s: string) => void)
return false
}
}, [address, chainId, approveWrite, status])
const executeSupplyCollateral = useCallback(async (token: Token, amount: string) => {
if (status === 'supplying' || status === 'success' || status === 'error') return false
if (!address || !chainId || !token?.contractAddress || !amount) {
setError('Missing required parameters')
return false
}
if (!isValidAmount(amount)) {
setError('Invalid amount')
return false
}
try {
setError(null)
setStatus('supplying')
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
if (!lendingProxyAddress) {
throw new Error('Contract not deployed on this chain')
}
const decimals = token.onChainDecimals ?? token.decimals
const amountInWei = parseUnits(amount, decimals)
await supplyWrite({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'supplyCollateral',
args: [token.contractAddress as `0x${string}`, amountInWei],
})
return true
} catch (err: unknown) {
handleContractCatchError(err, 'Supply collateral failed', setError, setStatus as (s: string) => void)
return false
}
}, [address, chainId, supplyWrite, status])
const executeApproveAndSupply = useCallback(async (token: Token, amount: string) => {
if (status !== 'idle') return
setPendingToken(token)
setPendingAmount(amount)
const approveSuccess = await executeApprove(token, amount)
if (!approveSuccess) return
}, [executeApprove, status])
// Auto-execute supply when approve is successful
useEffect(() => {
if (isApproveSuccess && status === 'approving' && pendingToken && pendingAmount) {
setStatus('approved')
executeSupplyCollateral(pendingToken, pendingAmount)
}
}, [isApproveSuccess, status, pendingToken, pendingAmount, executeSupplyCollateral])
useEffect(() => {
if (isApproveError && status === 'approving') {
setError(parseContractError(approveReceiptError))
setStatus('error')
}
}, [isApproveError, status, approveReceiptError])
useEffect(() => {
if (isSupplySuccess && status === 'supplying') {
setStatus('success')
}
}, [isSupplySuccess, status])
useEffect(() => {
if (isSupplyError && status === 'supplying') {
setError(parseContractError(supplyReceiptError))
setStatus('error')
}
}, [isSupplyError, status, supplyReceiptError])
const reset = useCallback(() => {
setStatus('idle')
setError(null)
setPendingToken(null)
setPendingAmount('')
resetApprove()
resetSupply()
}, [resetApprove, resetSupply])
return {
status,
error,
isLoading: isApprovePending || isApproveConfirming || isSupplyPending || isSupplyConfirming,
approveHash,
supplyHash,
executeApprove,
executeSupplyCollateral,
executeApproveAndSupply,
reset,
}
}
/**
* Query user's wallet balance of a specific YT token (ERC20 balanceOf)
*/
export function useYTWalletBalance(token: Token | undefined) {
const { address } = useAccount()
const { data: balance, isLoading, refetch } = useReadContract({
address: token?.contractAddress as `0x${string}` | undefined,
abi: abis.YTToken,
functionName: 'balanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!token?.contractAddress,
retry: false,
},
})
const decimals = token?.onChainDecimals ?? token?.decimals ?? 18
const formattedBalance = balance
? formatUnits(balance as bigint, decimals)
: '0.00'
return {
balance: balance as bigint || 0n,
formattedBalance,
isLoading,
refetch,
}
}
/**
* Query collateral balance for a specific YT token
*/
export function useCollateralBalance(token: Token | undefined) {
const { address, chainId } = useAccount()
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const {
data: balance,
isLoading,
refetch
} = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'userCollateral',
args: address && token?.contractAddress ? [address, token.contractAddress as `0x${string}`] : undefined,
query: {
enabled: !!address && !!lendingProxyAddress && !!token?.contractAddress,
retry: false,
},
})
const decimals = token?.onChainDecimals ?? token?.decimals ?? 18
const formattedBalance = balance
? formatUnits(balance as bigint, decimals)
: '0.00'
return {
balance: balance as bigint || 0n,
formattedBalance,
isLoading,
refetch,
}
}

View File

@@ -0,0 +1,215 @@
import { useState, useCallback, useEffect } from 'react'
import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract } from 'wagmi'
import { parseUnits, formatUnits } from 'viem'
import { abis, getContractAddress } from '@/lib/contracts'
import { useTokenList } from './useTokenList'
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
type SupplyStatus = 'idle' | 'approving' | 'approved' | 'supplying' | 'success' | 'error'
export function useLendingSupply() {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const usdcToken = bySymbol['USDC']
const [status, setStatus] = useState<SupplyStatus>('idle')
const [error, setError] = useState<string | null>(null)
const [pendingAmount, setPendingAmount] = useState<string>('')
const {
writeContractAsync: approveWrite,
data: approveHash,
isPending: isApprovePending,
reset: resetApprove,
} = useWriteContract()
const {
isLoading: isApproveConfirming,
isSuccess: isApproveSuccess,
isError: isApproveError,
error: approveReceiptError,
} = useWaitForTransactionReceipt({ hash: approveHash })
const {
writeContractAsync: supplyWrite,
data: supplyHash,
isPending: isSupplyPending,
reset: resetSupply,
} = useWriteContract()
const {
isLoading: isSupplyConfirming,
isSuccess: isSupplySuccess,
isError: isSupplyError,
error: supplyReceiptError,
} = useWaitForTransactionReceipt({ hash: supplyHash })
const executeApprove = useCallback(async (amount: string) => {
if (status !== 'idle') return false
if (!address || !chainId || !usdcToken?.contractAddress || !amount) {
setError('Missing required parameters')
return false
}
if (!isValidAmount(amount)) {
setError('Invalid amount')
return false
}
try {
setError(null)
setStatus('approving')
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
if (!lendingProxyAddress) {
throw new Error('Contract not deployed on this chain')
}
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
const amountInWei = parseUnits(amount, usdcDecimals)
await approveWrite({
address: usdcToken.contractAddress as `0x${string}`,
abi: abis.USDY,
functionName: 'approve',
args: [lendingProxyAddress, amountInWei],
})
return true
} catch (err: unknown) {
handleContractCatchError(err, 'Approve failed', setError, setStatus as (s: string) => void)
return false
}
}, [address, chainId, approveWrite, usdcToken, status])
const executeSupply = useCallback(async (amount: string) => {
if (status === 'supplying' || status === 'success' || status === 'error') return false
if (!address || !chainId || !usdcToken || !amount) {
setError('Missing required parameters')
return false
}
if (!isValidAmount(amount)) {
setError('Invalid amount')
return false
}
try {
setError(null)
setStatus('supplying')
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
if (!lendingProxyAddress) {
throw new Error('Contract not deployed on this chain')
}
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
const amountInWei = parseUnits(amount, usdcDecimals)
await supplyWrite({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'supply',
args: [amountInWei],
})
return true
} catch (err: unknown) {
handleContractCatchError(err, 'Supply failed', setError, setStatus as (s: string) => void)
return false
}
}, [address, chainId, supplyWrite, usdcToken, status])
const executeApproveAndSupply = useCallback(async (amount: string) => {
if (status !== 'idle') return
setPendingAmount(amount)
const approveSuccess = await executeApprove(amount)
if (!approveSuccess) return
}, [executeApprove, status])
// Auto-execute supply when approve is successful
useEffect(() => {
if (isApproveSuccess && status === 'approving' && pendingAmount) {
setStatus('approved')
executeSupply(pendingAmount)
}
}, [isApproveSuccess, status, pendingAmount, executeSupply])
useEffect(() => {
if (isApproveError && status === 'approving') {
setError(parseContractError(approveReceiptError))
setStatus('error')
}
}, [isApproveError, status, approveReceiptError])
useEffect(() => {
if (isSupplySuccess && status === 'supplying') {
setStatus('success')
}
}, [isSupplySuccess, status])
useEffect(() => {
if (isSupplyError && status === 'supplying') {
setError(parseContractError(supplyReceiptError))
setStatus('error')
}
}, [isSupplyError, status, supplyReceiptError])
const reset = useCallback(() => {
setStatus('idle')
setError(null)
setPendingAmount('')
resetApprove()
resetSupply()
}, [resetApprove, resetSupply])
return {
status,
error,
isLoading: isApprovePending || isApproveConfirming || isSupplyPending || isSupplyConfirming,
approveHash,
supplyHash,
executeApprove,
executeSupply,
executeApproveAndSupply,
reset,
}
}
// Query supplied balance
export function useSuppliedBalance() {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const usdcToken = bySymbol['USDC']
const lendingProxyAddress = chainId ? getContractAddress('lendingProxy', chainId) : undefined
const {
data: balance,
error: queryError,
isError,
isLoading,
refetch
} = useReadContract({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'supplyBalanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!lendingProxyAddress,
retry: false,
},
})
if (isError && queryError) {
console.error('[useSuppliedBalance] Query error:', queryError)
}
const usdcDecimals = usdcToken?.onChainDecimals ?? usdcToken?.decimals ?? 18
const suppliedBalance = balance ? (balance as bigint) : 0n
const formattedBalance = suppliedBalance > 0n
? (Number(suppliedBalance) / Math.pow(10, usdcDecimals)).toFixed(2)
: '0.00'
return {
balance: suppliedBalance,
formattedBalance,
refetch,
}
}

View File

@@ -0,0 +1,97 @@
import { useState, useCallback, useEffect } from 'react'
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { parseUnits } from 'viem'
import { abis, getContractAddress } from '@/lib/contracts'
import { useTokenList } from './useTokenList'
import { handleContractCatchError, isValidAmount, parseContractError } from '@/lib/errors'
import { WITHDRAW_GAS_LIMIT } from '@/lib/constants'
type WithdrawStatus = 'idle' | 'withdrawing' | 'success' | 'error'
export function useLendingWithdraw() {
const { address, chainId } = useAccount()
const { bySymbol } = useTokenList()
const usdcToken = bySymbol['USDC']
const [status, setStatus] = useState<WithdrawStatus>('idle')
const [error, setError] = useState<string | null>(null)
const {
writeContractAsync: withdrawWrite,
data: withdrawHash,
isPending: isWithdrawPending,
reset: resetWithdraw,
} = useWriteContract()
const {
isLoading: isWithdrawConfirming,
isSuccess: isWithdrawSuccess,
isError: isWithdrawError,
error: withdrawReceiptError,
} = useWaitForTransactionReceipt({ hash: withdrawHash })
const executeWithdraw = useCallback(async (amount: string) => {
if (status !== 'idle') return false
if (!address || !chainId || !usdcToken || !amount) {
setError('Missing required parameters')
return false
}
if (!isValidAmount(amount)) {
setError('Invalid amount')
return false
}
try {
setError(null)
setStatus('withdrawing')
const lendingProxyAddress = getContractAddress('lendingProxy', chainId)
if (!lendingProxyAddress) {
throw new Error('Contract not deployed on this chain')
}
const usdcDecimals = usdcToken.onChainDecimals ?? usdcToken.decimals
const amountInWei = parseUnits(amount, usdcDecimals)
await withdrawWrite({
address: lendingProxyAddress,
abi: abis.lendingProxy,
functionName: 'withdraw',
args: [amountInWei],
gas: WITHDRAW_GAS_LIMIT,
})
return true
} catch (err: unknown) {
handleContractCatchError(err, 'Withdraw failed', setError, setStatus as (s: string) => void)
return false
}
}, [address, chainId, withdrawWrite, usdcToken, status])
useEffect(() => {
if (isWithdrawSuccess && status === 'withdrawing') {
setStatus('success')
}
}, [isWithdrawSuccess, status])
useEffect(() => {
if (isWithdrawError && status === 'withdrawing') {
setError(parseContractError(withdrawReceiptError))
setStatus('error')
}
}, [isWithdrawError, status, withdrawReceiptError])
const reset = useCallback(() => {
setStatus('idle')
setError(null)
resetWithdraw()
}, [resetWithdraw])
return {
status,
error,
isLoading: isWithdrawPending || isWithdrawConfirming,
withdrawHash,
executeWithdraw,
reset,
}
}

Some files were not shown because too many files have changed in this diff Show More