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