Clear existing content
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.next/
|
|
||||||
.env.local
|
|
||||||
.DS_Store
|
|
||||||
.trae/
|
|
||||||
*.log
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# 部署指南
|
|
||||||
|
|
||||||
## 生产环境部署步骤
|
|
||||||
|
|
||||||
### 1. Vercel部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装Vercel CLI
|
|
||||||
npm install -g vercel
|
|
||||||
|
|
||||||
# 登录Vercel
|
|
||||||
vercel login
|
|
||||||
|
|
||||||
# 部署项目
|
|
||||||
vercel --prod
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 环境变量配置
|
|
||||||
|
|
||||||
在Vercel项目设置中添加以下环境变量:
|
|
||||||
|
|
||||||
**支付宝配置:**
|
|
||||||
- `ALIPAY_PARTNER_ID`: 2088511801157159
|
|
||||||
- `ALIPAY_KEY`: lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp
|
|
||||||
- `ALIPAY_APP_ID`: wx432c93e275548671
|
|
||||||
- `ALIPAY_RETURN_URL`: https://your-domain.com/payment/success
|
|
||||||
- `ALIPAY_NOTIFY_URL`: https://your-domain.com/api/payment/alipay/notify
|
|
||||||
|
|
||||||
**微信支付配置:**
|
|
||||||
- `WECHAT_APP_ID`: wx432c93e275548671
|
|
||||||
- `WECHAT_APP_SECRET`: 25b7e7fdb7998e5107e242ebb6ddabd0
|
|
||||||
- `WECHAT_MCH_ID`: 1318592501
|
|
||||||
- `WECHAT_API_KEY`: wx3e31b068be59ddc131b068be59ddc2
|
|
||||||
- `WECHAT_NOTIFY_URL`: https://your-domain.com/api/payment/wechat/notify
|
|
||||||
|
|
||||||
**基础配置:**
|
|
||||||
- `NEXT_PUBLIC_BASE_URL`: https://your-domain.com
|
|
||||||
|
|
||||||
### 3. 域名配置
|
|
||||||
|
|
||||||
1. 在Vercel项目设置中绑定自定义域名
|
|
||||||
2. 配置DNS记录指向Vercel
|
|
||||||
3. 启用HTTPS(Vercel自动配置SSL证书)
|
|
||||||
|
|
||||||
### 4. 支付回调配置
|
|
||||||
|
|
||||||
**支付宝配置:**
|
|
||||||
1. 登录支付宝开放平台
|
|
||||||
2. 在应用详情中配置异步通知地址:`https://your-domain.com/api/payment/alipay/notify`
|
|
||||||
3. 配置同步返回地址:`https://your-domain.com/payment/success`
|
|
||||||
|
|
||||||
**微信支付配置:**
|
|
||||||
1. 登录微信商户平台
|
|
||||||
2. 在产品中心配置支付回调URL:`https://your-domain.com/api/payment/wechat/notify`
|
|
||||||
3. 添加支付授权域名:`your-domain.com`
|
|
||||||
|
|
||||||
### 5. 测试流程
|
|
||||||
|
|
||||||
1. 创建测试订单
|
|
||||||
2. 使用沙箱环境测试支付宝支付
|
|
||||||
3. 使用微信开发者工具测试微信支付
|
|
||||||
4. 验证回调接口正常接收
|
|
||||||
5. 确认订单状态更新正确
|
|
||||||
6. 验证内容解锁功能
|
|
||||||
|
|
||||||
### 6. 监控和日志
|
|
||||||
|
|
||||||
- 在Vercel Dashboard查看部署日志
|
|
||||||
- 使用Vercel Analytics监控访问数据
|
|
||||||
- 配置错误告警通知
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装依赖
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 启动开发服务器
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# 访问 http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. 生产环境必须使用HTTPS
|
|
||||||
2. 定期更新支付密钥
|
|
||||||
3. 保护环境变量安全
|
|
||||||
4. 备份用户数据
|
|
||||||
5. 监控支付异常
|
|
||||||
30
README.md
30
README.md
@@ -1,30 +0,0 @@
|
|||||||
# AI phone branding
|
|
||||||
|
|
||||||
*Automatically synced with your [v0.app](https://v0.app) deployments*
|
|
||||||
|
|
||||||
[](https://vercel.com/fnvtks-projects/v0--ap)
|
|
||||||
[](https://v0.app/chat/tPF15XbLAKD)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This repository will stay in sync with your deployed chats on [v0.app](https://v0.app).
|
|
||||||
Any changes you make to your deployed app will be automatically pushed to this repository from [v0.app](https://v0.app).
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
Your project is live at:
|
|
||||||
|
|
||||||
**[https://vercel.com/fnvtks-projects/v0--ap](https://vercel.com/fnvtks-projects/v0--ap)**
|
|
||||||
|
|
||||||
## Build your app
|
|
||||||
|
|
||||||
Continue building your app on:
|
|
||||||
|
|
||||||
**[https://v0.app/chat/tPF15XbLAKD](https://v0.app/chat/tPF15XbLAKD)**
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. Create and modify your project using [v0.app](https://v0.app)
|
|
||||||
2. Deploy your chats from the v0 interface
|
|
||||||
3. Changes are automatically pushed to this repository
|
|
||||||
4. Vercel deploys the latest version from this repository
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Mon Dec 29 18:11:24 CST 2025
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# 通用支付模块 API 接口定义 (Universal Payment API)
|
|
||||||
|
|
||||||
无论后端使用何种语言(Python/Node/Go),请严格实现以下 RESTful 接口。
|
|
||||||
|
|
||||||
## 1. 核心交易接口 (Core Transaction)
|
|
||||||
|
|
||||||
### 1.1 创建订单
|
|
||||||
* **URL**: `POST /api/payment/create_order`
|
|
||||||
* **Description**: 业务系统通知支付模块创建一个待支付订单。
|
|
||||||
* **Request Body**:
|
|
||||||
\`\`\`json
|
|
||||||
{
|
|
||||||
"user_id": "u1001", // 用户ID
|
|
||||||
"title": "VIP Membership", // 订单标题
|
|
||||||
"amount": 99.00, // 金额 (法币单位: 元 / 美元)
|
|
||||||
"currency": "CNY", // 币种: CNY, USD
|
|
||||||
"product_id": "vip_01", // 商品ID
|
|
||||||
"extra_params": {} // 扩展参数
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
* **Response**:
|
|
||||||
\`\`\`json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"data": {
|
|
||||||
"order_sn": "202310270001", // 支付系统生成的唯一订单号
|
|
||||||
"status": "created"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 1.2 发起支付 (收银台)
|
|
||||||
* **URL**: `POST /api/payment/checkout`
|
|
||||||
* **Description**: 用户选择支付方式后,获取支付参数。
|
|
||||||
* **Request Body**:
|
|
||||||
\`\`\`json
|
|
||||||
{
|
|
||||||
"order_sn": "202310270001",
|
|
||||||
"gateway": "alipay_qr", // 支付方式: alipay_qr, wechat_jsapi, paypal, usdt, stripe
|
|
||||||
"return_url": "https://...", // 支付成功后前端跳转地址
|
|
||||||
"openid": "..." // 微信JSAPI支付必填
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
* **Response**:
|
|
||||||
\`\`\`json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"data": {
|
|
||||||
"type": "url", // url (跳转), qrcode (扫码), json (SDK参数), address (USDT)
|
|
||||||
"payload": "https://...", // 具体内容
|
|
||||||
"expiration": 1800 // 过期时间(秒)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 1.3 查询订单状态
|
|
||||||
* **URL**: `GET /api/payment/status/{order_sn}`
|
|
||||||
* **Description**: 前端轮询使用。
|
|
||||||
* **Response**:
|
|
||||||
\`\`\`json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"data": {
|
|
||||||
"status": "paid", // created, paying, paid, closed
|
|
||||||
"paid_amount": 99.00,
|
|
||||||
"paid_at": "2023-10-27 10:00:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 回调通知接口 (Webhook)
|
|
||||||
|
|
||||||
### 2.1 统一回调入口
|
|
||||||
* **URL**: `POST /api/payment/notify/{gateway}`
|
|
||||||
* **Description**: 接收第三方支付平台的异步通知。
|
|
||||||
* **Path Params**:
|
|
||||||
* `gateway`: `alipay`, `wechat`, `paypal`, `stripe`, `nowpayments`
|
|
||||||
* **Logic**:
|
|
||||||
1. 根据 gateway 加载对应驱动。
|
|
||||||
2. 验签 (Verify Signature)。
|
|
||||||
3. 幂等性检查 (Idempotency Check)。
|
|
||||||
4. 更新订单状态。
|
|
||||||
5. 返回平台所需的响应字符串 (e.g. `success`, `200 OK`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 辅助接口
|
|
||||||
|
|
||||||
### 3.1 获取汇率
|
|
||||||
* **URL**: `GET /api/payment/exchange_rate`
|
|
||||||
* **Params**: `from=USD&to=CNY`
|
|
||||||
* **Response**: `{"rate": 7.21}`
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# 全球支付模块标准配置模板 (Standard Config)
|
|
||||||
|
|
||||||
# 无论你使用 Python, Node.js, Go 还是 Java,请将此配置映射到你的环境(如 .env, config.py, config.js)
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# 1. 基础环境 (Environment)
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
APP_ENV: "production" # development / production
|
|
||||||
APP_URL: "https://your-site.com" # 你的网站域名 (用于回调)
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# 2. 数据库 (Database)
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# 系统会自动生成 order 和 pay_trade 表
|
|
||||||
DB_CONNECTION: "mysql" # mysql / postgres / sqlite
|
|
||||||
DB_HOST: "127.0.0.1"
|
|
||||||
DB_PORT: "3306"
|
|
||||||
DB_DATABASE: "payment_db"
|
|
||||||
DB_USERNAME: "root"
|
|
||||||
DB_PASSWORD: "password"
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# 3. 支付宝 (Alipay) - CN
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
ALIPAY_ENABLED: true
|
|
||||||
ALIPAY_APP_ID: "20210001..."
|
|
||||||
ALIPAY_PRIVATE_KEY: "MIIETv..." # 商户私钥
|
|
||||||
ALIPAY_PUBLIC_KEY: "MIIBIj..." # 支付宝公钥
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# 4. 微信支付 (Wechat Pay) - CN
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
WECHAT_ENABLED: true
|
|
||||||
WECHAT_APP_ID: "wx123456..." # 公众号/小程序 AppID
|
|
||||||
WECHAT_MCH_ID: "1234567890" # 商户号
|
|
||||||
WECHAT_API_V3_KEY: "abcdef..." # APIv3 密钥 (32位)
|
|
||||||
WECHAT_CERT_SERIAL: "45F59C..." # 证书序列号
|
|
||||||
WECHAT_PRIVATE_KEY_PATH: "./cert/apiclient_key.pem"
|
|
||||||
WECHAT_CERT_PATH: "./cert/apiclient_cert.pem"
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# 5. PayPal - Global
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
PAYPAL_ENABLED: true
|
|
||||||
PAYPAL_MODE: "live" # sandbox / live
|
|
||||||
PAYPAL_CLIENT_ID: "Af7s8..."
|
|
||||||
PAYPAL_CLIENT_SECRET: "EKd9..."
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# 6. Stripe - Global
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
STRIPE_ENABLED: true
|
|
||||||
STRIPE_PUBLIC_KEY: "pk_live_..."
|
|
||||||
STRIPE_SECRET_KEY: "sk_live_..."
|
|
||||||
STRIPE_WEBHOOK_SECRET: "whsec_..."
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
# 7. USDT (Crypto) - Web3
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
USDT_ENABLED: true
|
|
||||||
USDT_GATEWAY_TYPE: "nowpayments" # nowpayments / native (原生监听)
|
|
||||||
|
|
||||||
# 选项 A: NOWPayments (第三方网关)
|
|
||||||
NOWPAYMENTS_API_KEY: "R1G..."
|
|
||||||
NOWPAYMENTS_IPN_SECRET: "secret..."
|
|
||||||
|
|
||||||
# 选项 B: Native (原生监听 - TRC20)
|
|
||||||
TRON_NODE_API: "https://api.trongrid.io"
|
|
||||||
TRON_WALLET_ADDRESS: "T9yD14Nj9..." # 你的收款地址
|
|
||||||
TRON_CHECK_INTERVAL: 60 # 轮询间隔 (秒)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# 通用支付模块智能对接指令 (AI Integration Prompt) v3.0
|
|
||||||
|
|
||||||
**角色设定**: 你是一位精通全球支付架构(Alipay/Wechat/PayPal/Stripe/USDT)的资深全栈架构师。
|
|
||||||
|
|
||||||
**任务目标**:
|
|
||||||
我提供了一个**完全配置驱动 (Configuration-Driven)** 的通用支付模块设计。
|
|
||||||
请你根据我的目标项目环境,将此支付功能无缝集成进去。
|
|
||||||
|
|
||||||
**核心资源 (Input)**:
|
|
||||||
1. **标准配置模板**: `1_核心设计_通用协议/标准配置模板.yaml` (所有支付参数的 Key)
|
|
||||||
2. **API 接口契约**: `1_核心设计_通用协议/API接口定义.md` (标准 RESTful 接口)
|
|
||||||
3. **核心业务模型**: `1_核心设计_通用协议/业务逻辑与模型.md` (数据库表结构)
|
|
||||||
|
|
||||||
**集成模式 (选择一种)**:
|
|
||||||
|
|
||||||
### 模式 A: 嵌入式集成 (Library Mode) - *推荐*
|
|
||||||
适用于将支付功能直接写在现有的后端项目中 (如 Django app, NestJS module)。
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. **环境识别**: 检查我的项目语言 (Python/Node/Go/Java)。
|
|
||||||
2. **依赖安装**: 根据语言推荐 SDK (e.g. `alipay-sdk-python`, `stripe`).
|
|
||||||
3. **配置加载**: 创建代码读取 `标准配置模板.yaml` 中的环境变量。
|
|
||||||
4. **模型生成**: 根据 `业务逻辑与模型.md` 生成 ORM 代码 (User/Order/PayTrade)。
|
|
||||||
5. **接口实现**: 严格按照 `API接口定义.md` 实现 Controller/View。
|
|
||||||
* *要求*: 使用工厂模式 (`PaymentFactory`) 管理不同网关。
|
|
||||||
|
|
||||||
### 模式 B: 微服务集成 (Microservice Mode)
|
|
||||||
适用于将支付功能独立部署为一个 Docker 容器。
|
|
||||||
|
|
||||||
**步骤**:
|
|
||||||
1. **服务生成**: 用 Go (Gin) 或 Node.js (Express) 生成一个独立服务。
|
|
||||||
2. **Docker化**: 编写 `Dockerfile` 和 `docker-compose.yml`。
|
|
||||||
3. **网关代理**: 配置 Nginx 或 API Gateway 将 `/api/payment` 转发给此服务。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**给 AI 的执行指令 (Prompt)**:
|
|
||||||
|
|
||||||
> "请读取 `Universal_Payment_Module` 目录下的所有设计文档。
|
|
||||||
> 我的当前项目是基于 **[你的语言/框架]** 的。
|
|
||||||
> 请采用 **[模式 A / 模式 B]** 为我集成支付功能。
|
|
||||||
> 1. 首先生成依赖安装命令。
|
|
||||||
> 2. 然后生成数据库模型代码。
|
|
||||||
> 3. 最后实现符合 `API接口定义.md` 的核心接口代码。"
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>通用收银台 Demo</title>
|
|
||||||
<style>
|
|
||||||
/* 简单的内联样式,实际使用建议用 TailwindCSS */
|
|
||||||
body { font-family: -apple-system, sans-serif; background: #f5f5f7; padding: 20px; }
|
|
||||||
.container { max-width: 480px; margin: 0 auto; background: white; border-radius: 12px; padding: 24px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
|
||||||
.order-info { margin-bottom: 24px; border-bottom: 1px solid #eee; padding-bottom: 16px; }
|
|
||||||
.amount { font-size: 32px; font-weight: bold; color: #333; }
|
|
||||||
.payment-methods { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.method-btn { display: flex; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid #ddd; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s; }
|
|
||||||
.method-btn:hover { border-color: #007aff; background: #f0f7ff; }
|
|
||||||
.method-btn.active { border-color: #007aff; box-shadow: 0 0 0 2px rgba(0,122,255,0.2); }
|
|
||||||
.btn-pay { width: 100%; background: #007aff; color: white; border: none; padding: 16px; border-radius: 8px; font-size: 16px; font-weight: bold; margin-top: 24px; cursor: pointer; }
|
|
||||||
.btn-pay:disabled { background: #ccc; }
|
|
||||||
.qrcode-area { text-align: center; margin-top: 20px; display: none; }
|
|
||||||
.qrcode-area img { width: 200px; height: 200px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="order-info">
|
|
||||||
<div style="color: #666; font-size: 14px;">订单支付</div>
|
|
||||||
<div class="amount" id="displayAmount">¥ 99.00</div>
|
|
||||||
<div style="margin-top: 8px; font-size: 14px;">订单号: <span id="orderSn">202310270001</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="payment-methods" id="paymentMethods">
|
|
||||||
<!-- 支付宝 -->
|
|
||||||
<div class="method-btn" onclick="selectMethod('alipay_qr')">
|
|
||||||
<span>🔵 支付宝 (Alipay)</span>
|
|
||||||
<input type="radio" name="method" value="alipay_qr">
|
|
||||||
</div>
|
|
||||||
<!-- 微信 -->
|
|
||||||
<div class="method-btn" onclick="selectMethod('wechat_scan')">
|
|
||||||
<span>🟢 微信支付 (Wechat)</span>
|
|
||||||
<input type="radio" name="method" value="wechat_scan">
|
|
||||||
</div>
|
|
||||||
<!-- PayPal -->
|
|
||||||
<div class="method-btn" onclick="selectMethod('paypal')">
|
|
||||||
<span>🅿️ PayPal (USD)</span>
|
|
||||||
<input type="radio" name="method" value="paypal">
|
|
||||||
</div>
|
|
||||||
<!-- USDT -->
|
|
||||||
<div class="method-btn" onclick="selectMethod('usdt')">
|
|
||||||
<span>🪙 USDT (TRC20)</span>
|
|
||||||
<input type="radio" name="method" value="usdt">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="qrcode-area" id="qrcodeArea">
|
|
||||||
<img id="qrcodeImg" src="" alt="QRCode">
|
|
||||||
<p id="qrcodeText">请扫描二维码支付</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn-pay" onclick="doPay()">确认支付</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let selectedMethod = null;
|
|
||||||
const API_BASE = '/api/payment'; // 你的后端接口地址
|
|
||||||
|
|
||||||
function selectMethod(method) {
|
|
||||||
selectedMethod = method;
|
|
||||||
document.querySelectorAll('.method-btn').forEach(el => el.classList.remove('active'));
|
|
||||||
event.currentTarget.classList.add('active');
|
|
||||||
document.querySelector(`input[value="${method}"]`).checked = true;
|
|
||||||
|
|
||||||
// 如果选了USDT,可能需要先换算汇率(模拟)
|
|
||||||
if(method === 'usdt') {
|
|
||||||
document.getElementById('displayAmount').innerText = '₮ 13.88'; // 模拟 99 CNY -> USDT
|
|
||||||
} else if(method === 'paypal') {
|
|
||||||
document.getElementById('displayAmount').innerText = '$ 13.75'; // 模拟 99 CNY -> USD
|
|
||||||
} else {
|
|
||||||
document.getElementById('displayAmount').innerText = '¥ 99.00';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doPay() {
|
|
||||||
if (!selectedMethod) {
|
|
||||||
alert('请选择支付方式');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderSn = document.getElementById('orderSn').innerText;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 调用后端通用接口
|
|
||||||
const response = await fetch(`${API_BASE}/checkout`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
order_sn: orderSn,
|
|
||||||
gateway: selectedMethod,
|
|
||||||
return_url: window.location.href
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.data.type === 'url') {
|
|
||||||
// 跳转类 (PayPal, H5)
|
|
||||||
window.location.href = result.data.payload;
|
|
||||||
} else if (result.data.type === 'qrcode') {
|
|
||||||
// 扫码类 (Wechat, Alipay)
|
|
||||||
document.getElementById('qrcodeArea').style.display = 'block';
|
|
||||||
document.getElementById('qrcodeImg').src = `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(result.data.payload)}`;
|
|
||||||
startPolling(orderSn);
|
|
||||||
} else if (result.data.type === 'address') {
|
|
||||||
// 加密货币类
|
|
||||||
document.getElementById('qrcodeArea').style.display = 'block';
|
|
||||||
document.getElementById('qrcodeText').innerText = `请转账至: ${result.data.payload}`;
|
|
||||||
// 生成地址二维码...
|
|
||||||
startPolling(orderSn);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('支付发起失败', error);
|
|
||||||
alert('支付发起失败,请查看控制台');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 轮询查单
|
|
||||||
function startPolling(orderSn) {
|
|
||||||
const timer = setInterval(async () => {
|
|
||||||
const res = await fetch(`${API_BASE}/status/${orderSn}`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.data.status === 'paid') {
|
|
||||||
clearInterval(timer);
|
|
||||||
alert('支付成功!');
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# 全球通用支付模块 (Universal Payment Module) v3.0
|
|
||||||
|
|
||||||
这是一个**配置驱动 (Configuration-Driven)**、**API 优先 (API-First)** 的全球支付解决方案包。
|
|
||||||
它通过标准化的协议和 AI 指令,让任何语言的项目都能在 5 分钟内接入支付宝、微信、PayPal、Stripe 和 USDT。
|
|
||||||
|
|
||||||
## 📂 模块结构 (Directory Structure)
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
Universal_Payment_Module/
|
|
||||||
├── 1_核心设计_通用协议/ # [灵魂] 定义了支付的“法律”
|
|
||||||
│ ├── 标准配置模板.yaml # [新增] 填空即可配置所有支付参数
|
|
||||||
│ ├── API接口定义.md # [新增] 无论用什么语言,接口都长这样
|
|
||||||
│ ├── 业务逻辑与模型.md # 数据库表结构设计 (Order/PayTrade)
|
|
||||||
│ └── 接口注册指南.md # 申请 Key 的教程
|
|
||||||
│
|
|
||||||
├── 2_智能对接_AI指令/ # [工具] AI 编译器
|
|
||||||
│ └── 通用集成指令.md # 发给 AI,自动生成代码
|
|
||||||
│
|
|
||||||
├── 3_逻辑参考_通用实现/ # [参考]
|
|
||||||
│ ├── 前端收银台Demo.html # [新增] 原生 JS 实现的通用收银台
|
|
||||||
│ ├── 后端源码/ # PHP 参考实现
|
|
||||||
│ └── 前端模板/ # Twig 参考模板
|
|
||||||
│
|
|
||||||
└── README.md # 本说明文档
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## 🚀 极速对接 (Integration Guide)
|
|
||||||
|
|
||||||
### 第一步:配置 (Config)
|
|
||||||
1. 打开 `1_核心设计_通用协议/标准配置模板.yaml`。
|
|
||||||
2. 将文件内容复制到你项目的配置文件中(如 `.env` 或 `config.py`)。
|
|
||||||
3. 填入你申请到的 `APP_ID`, `PRIVATE_KEY` 等参数。
|
|
||||||
|
|
||||||
### 第二步:生成代码 (Generate)
|
|
||||||
1. 复制 `2_智能对接_AI指令/通用集成指令.md` 的内容。
|
|
||||||
2. 打开 AI 助手,发送指令:
|
|
||||||
> "我的项目是用 **Python FastAPI** 写的。请根据上述文档,采用 **模式 A (嵌入式)** 为我集成支付功能。"
|
|
||||||
3. AI 会为你生成:
|
|
||||||
* `pip install ...` 命令
|
|
||||||
* `models.py` (数据库模型)
|
|
||||||
* `payment_router.py` (API 接口)
|
|
||||||
|
|
||||||
### 第三步:前端接入 (Frontend)
|
|
||||||
1. 参考 `3_逻辑参考_通用实现/前端收银台Demo.html`。
|
|
||||||
2. 将其中的 `API_BASE` 替换为你后端实际的 API 地址。
|
|
||||||
3. 即可拥有一个支持 **扫码、跳转、加密货币支付** 的全功能收银台。
|
|
||||||
|
|
||||||
## 🌍 支持能力
|
|
||||||
| 渠道 | 能力 | 适用场景 |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| **Alipay / Wechat** | 扫码 / H5 / APP | 中国市场 (CNY) |
|
|
||||||
| **PayPal / Stripe** | 信用卡 / 订阅 | 全球市场 (USD/EUR...) |
|
|
||||||
| **USDT (TRC20)** | 链上转账 / 监听 | Web3 / 抗审查支付 |
|
|
||||||
|
|
||||||
## ✨ v3.0 优化亮点
|
|
||||||
* **配置驱动**: 不再需要改代码里的硬编码,所有参数通过配置文件注入。
|
|
||||||
* **API 契约**: 明确了输入输出格式,前后端对接不再扯皮。
|
|
||||||
* **前端 Demo**: 提供了一个不依赖任何框架的原生 JS 收银台,复制即用。
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { ChevronLeft, Clock, MessageCircle, BookOpen, Users, Award, TrendingUp } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { QRCodeModal } from "@/components/modules/marketing/qr-code-modal"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
export default function AboutPage() {
|
|
||||||
const [showQRModal, setShowQRModal] = useState(false)
|
|
||||||
const { settings } = useStore()
|
|
||||||
const authorInfo = settings?.authorInfo || {
|
|
||||||
name: "卡若",
|
|
||||||
description: "连续创业者,私域运营专家",
|
|
||||||
liveTime: "06:00-09:00",
|
|
||||||
platform: "Soul派对房",
|
|
||||||
}
|
|
||||||
|
|
||||||
const milestones = [
|
|
||||||
{ year: "2012", event: "开始做游戏推广,从魔兽世界外挂代理起步" },
|
|
||||||
{ year: "2015", event: "转型电商,做天猫虚拟充值,月流水380万" },
|
|
||||||
{ year: "2017", event: "团队扩张到200人,年流水3000万" },
|
|
||||||
{ year: "2018", event: "公司破产,负债数百万,开始全国旅行反思" },
|
|
||||||
{ year: "2019", event: "重新出发,专注私域运营和个人IP" },
|
|
||||||
{ year: "2024", event: "在Soul派对房每日直播,分享真实商业故事" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{ icon: BookOpen, value: "55+", label: "真实案例" },
|
|
||||||
{ icon: Users, value: "10000+", label: "派对房听众" },
|
|
||||||
{ icon: Award, value: "15年", label: "创业经验" },
|
|
||||||
{ icon: TrendingUp, value: "3000万", label: "最高年流水" },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0a1628] text-white pb-20">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="sticky top-0 z-50 bg-[#0a1628]/90 backdrop-blur-md border-b border-gray-800">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center">
|
|
||||||
<Link href="/" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors">
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
<span>返回</span>
|
|
||||||
</Link>
|
|
||||||
<h1 className="flex-1 text-center text-lg font-semibold">关于作者</h1>
|
|
||||||
<div className="w-16" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
{/* Author Card */}
|
|
||||||
<div className="bg-gradient-to-br from-[#38bdac]/20 to-[#0f2137] rounded-2xl p-8 border border-[#38bdac]/30 mb-8">
|
|
||||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
|
||||||
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-[#38bdac] to-[#1a5a50] flex items-center justify-center text-4xl font-bold text-white">
|
|
||||||
{authorInfo.name.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div className="text-center md:text-left flex-1">
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">{authorInfo.name}</h2>
|
|
||||||
<p className="text-gray-400 mb-4">{authorInfo.description}</p>
|
|
||||||
<div className="flex items-center justify-center md:justify-start gap-4 text-sm">
|
|
||||||
<span className="flex items-center gap-1 text-[#38bdac]">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
每日 {authorInfo.liveTime}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1 text-gray-400">
|
|
||||||
<MessageCircle className="w-4 h-4" />
|
|
||||||
{authorInfo.platform}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setShowQRModal(true)} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
|
||||||
<MessageCircle className="w-4 h-4 mr-2" />
|
|
||||||
加入派对群
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
||||||
{stats.map((stat, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-4 border border-gray-700/50 text-center"
|
|
||||||
>
|
|
||||||
<stat.icon className="w-6 h-6 text-[#38bdac] mx-auto mb-2" />
|
|
||||||
<p className="text-2xl font-bold text-white">{stat.value}</p>
|
|
||||||
<p className="text-gray-400 text-sm">{stat.label}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Introduction */}
|
|
||||||
<div className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-6 border border-gray-700/50 mb-8">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">关于这本书</h3>
|
|
||||||
<div className="space-y-4 text-gray-300 leading-relaxed">
|
|
||||||
<p>"这不是一本教你成功的鸡汤书。"</p>
|
|
||||||
<p>
|
|
||||||
这是我每天早上6点到9点,在Soul派对房和几百个陌生人分享的真实故事。 有人来找项目,有人来找钱,有人来找方向。
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
我见过凌晨四点还在撸运费险的年轻人,见过七十岁还在开滴滴做生意的老人,
|
|
||||||
见过一个月赚七八块却拼命倒卖游戏金币的大学生。
|
|
||||||
</p>
|
|
||||||
<p className="text-[#38bdac] font-semibold">"社会不是靠努力,是靠洞察与选择。"</p>
|
|
||||||
<p>
|
|
||||||
这本书,就是把那些在派对房里讲过的、能让人清醒的故事,整理成文字。每个案例都真实发生过,每个教训都是用钱换来的。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
|
||||||
<div className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-6 border border-gray-700/50 mb-8">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-6">创业历程</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{milestones.map((item, index) => (
|
|
||||||
<div key={index} className="flex gap-4">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-[#38bdac]" />
|
|
||||||
{index < milestones.length - 1 && <div className="w-0.5 h-full bg-gray-700 mt-1" />}
|
|
||||||
</div>
|
|
||||||
<div className="pb-4">
|
|
||||||
<p className="text-[#38bdac] font-semibold">{item.year}</p>
|
|
||||||
<p className="text-gray-300">{item.event}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<div className="bg-gradient-to-r from-[#38bdac]/10 to-[#1a3a4a]/50 rounded-2xl p-6 border border-[#38bdac]/30 text-center">
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">想听更多真实故事?</h3>
|
|
||||||
<p className="text-gray-400 mb-6">每天早上6-9点,卡若在Soul派对房免费分享</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<Button onClick={() => setShowQRModal(true)} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
|
||||||
<MessageCircle className="w-4 h-4 mr-2" />
|
|
||||||
加入派对群
|
|
||||||
</Button>
|
|
||||||
<Link href="/chapters">
|
|
||||||
<Button variant="outline" className="border-gray-600 text-white hover:bg-gray-700/50 bg-transparent">
|
|
||||||
<BookOpen className="w-4 h-4 mr-2" />
|
|
||||||
开始阅读
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<QRCodeModal isOpen={showQRModal} onClose={() => setShowQRModal(false)} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
|
||||||
import { bookData } from "@/lib/book-data"
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
BookOpen,
|
|
||||||
Settings2,
|
|
||||||
ChevronRight,
|
|
||||||
CheckCircle,
|
|
||||||
Edit3,
|
|
||||||
Save,
|
|
||||||
X,
|
|
||||||
RefreshCw,
|
|
||||||
Link2,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
interface EditingSection {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
price: number
|
|
||||||
content?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContentPage() {
|
|
||||||
const [expandedParts, setExpandedParts] = useState<string[]>(["part-1"])
|
|
||||||
const [editingSection, setEditingSection] = useState<EditingSection | null>(null)
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false)
|
|
||||||
const [feishuDocUrl, setFeishuDocUrl] = useState("")
|
|
||||||
const [showFeishuModal, setShowFeishuModal] = useState(false)
|
|
||||||
|
|
||||||
const togglePart = (partId: string) => {
|
|
||||||
setExpandedParts((prev) => (prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId]))
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalSections = bookData.reduce(
|
|
||||||
(sum, part) => sum + part.chapters.reduce((cSum, ch) => cSum + ch.sections.length, 0),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleEditSection = (section: { id: string; title: string; price: number }) => {
|
|
||||||
setEditingSection({
|
|
||||||
id: section.id,
|
|
||||||
title: section.title,
|
|
||||||
price: section.price,
|
|
||||||
content: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveSection = () => {
|
|
||||||
if (editingSection) {
|
|
||||||
// 保存到本地存储或API
|
|
||||||
console.log("[v0] Saving section:", editingSection)
|
|
||||||
alert(`已保存章节: ${editingSection.title}`)
|
|
||||||
setEditingSection(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSyncFeishu = async () => {
|
|
||||||
if (!feishuDocUrl) {
|
|
||||||
alert("请输入飞书文档链接")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsSyncing(true)
|
|
||||||
// 模拟同步过程
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
||||||
setIsSyncing(false)
|
|
||||||
setShowFeishuModal(false)
|
|
||||||
alert("飞书文档同步成功!")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-6xl mx-auto">
|
|
||||||
<div className="flex justify-between items-center mb-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-white">内容管理</h2>
|
|
||||||
<p className="text-gray-400 mt-1">
|
|
||||||
共 {bookData.length} 篇 · {totalSections} 节内容
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setShowFeishuModal(true)} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
|
||||||
同步飞书文档
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 飞书同步弹窗 */}
|
|
||||||
<Dialog open={showFeishuModal} onOpenChange={setShowFeishuModal}>
|
|
||||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-white flex items-center gap-2">
|
|
||||||
<Link2 className="w-5 h-5 text-[#38bdac]" />
|
|
||||||
同步飞书文档
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">飞书文档链接</Label>
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
placeholder="https://xxx.feishu.cn/docx/..."
|
|
||||||
value={feishuDocUrl}
|
|
||||||
onChange={(e) => setFeishuDocUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">请确保文档已开启公开访问权限</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-[#38bdac]/10 border border-[#38bdac]/30 rounded-lg p-3">
|
|
||||||
<p className="text-[#38bdac] text-sm">
|
|
||||||
同步说明:系统将自动解析飞书文档结构,按照标题层级导入为章节内容。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowFeishuModal(false)}
|
|
||||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSyncFeishu}
|
|
||||||
disabled={isSyncing}
|
|
||||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
|
||||||
>
|
|
||||||
{isSyncing ? (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
同步中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
开始同步
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 章节编辑弹窗 */}
|
|
||||||
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
|
|
||||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-white flex items-center gap-2">
|
|
||||||
<Edit3 className="w-5 h-5 text-[#38bdac]" />
|
|
||||||
编辑章节
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{editingSection && (
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">章节标题</Label>
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white"
|
|
||||||
value={editingSection.title}
|
|
||||||
onChange={(e) => setEditingSection({ ...editingSection, title: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">价格 (元)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white w-32"
|
|
||||||
value={editingSection.price}
|
|
||||||
onChange={(e) => setEditingSection({ ...editingSection, price: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">内容预览</Label>
|
|
||||||
<Textarea
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white min-h-[200px] placeholder:text-gray-500"
|
|
||||||
placeholder="此处显示章节内容,支持Markdown格式..."
|
|
||||||
value={editingSection.content}
|
|
||||||
onChange={(e) => setEditingSection({ ...editingSection, content: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEditingSection(null)}
|
|
||||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4 mr-2" />
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSaveSection} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
保存修改
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Tabs defaultValue="chapters" className="space-y-6">
|
|
||||||
<TabsList className="bg-[#0f2137] border border-gray-700/50 p-1">
|
|
||||||
<TabsTrigger
|
|
||||||
value="chapters"
|
|
||||||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
|
|
||||||
>
|
|
||||||
<BookOpen className="w-4 h-4 mr-2" />
|
|
||||||
章节管理
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="hooks"
|
|
||||||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
钩子配置
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="chapters" className="space-y-4">
|
|
||||||
{bookData.map((part, partIndex) => (
|
|
||||||
<Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden">
|
|
||||||
<CardHeader
|
|
||||||
className="cursor-pointer hover:bg-[#162840] transition-colors"
|
|
||||||
onClick={() => togglePart(part.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-[#38bdac] font-mono text-sm">0{partIndex + 1}</span>
|
|
||||||
<CardTitle className="text-white">{part.title}</CardTitle>
|
|
||||||
<Badge variant="outline" className="text-gray-400 border-gray-600">
|
|
||||||
{part.chapters.reduce((sum, ch) => sum + ch.sections.length, 0)} 节
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<ChevronRight
|
|
||||||
className={`w-5 h-5 text-gray-400 transition-transform ${
|
|
||||||
expandedParts.includes(part.id) ? "rotate-90" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{expandedParts.includes(part.id) && (
|
|
||||||
<CardContent className="pt-0 pb-4">
|
|
||||||
<div className="space-y-3 pl-8 border-l-2 border-gray-700">
|
|
||||||
{part.chapters.map((chapter) => (
|
|
||||||
<div key={chapter.id} className="space-y-2">
|
|
||||||
<h4 className="font-medium text-gray-300">{chapter.title}</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{chapter.sections.map((section) => (
|
|
||||||
<div
|
|
||||||
key={section.id}
|
|
||||||
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[#162840] text-sm group transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-[#38bdac]" />
|
|
||||||
<span className="text-gray-400">{section.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[#38bdac] font-medium">
|
|
||||||
{section.price === 0 ? "免费" : `¥${section.price}`}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEditSection(section)}
|
|
||||||
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4 mr-1" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="hooks" className="space-y-4">
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white">引流钩子配置</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
|
||||||
<Label htmlFor="hook-chapter" className="text-gray-300">
|
|
||||||
触发章节
|
|
||||||
</Label>
|
|
||||||
<Select defaultValue="3">
|
|
||||||
<SelectTrigger id="hook-chapter" className="bg-[#0a1628] border-gray-700 text-white">
|
|
||||||
<SelectValue placeholder="选择章节" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-[#0f2137] border-gray-700">
|
|
||||||
<SelectItem value="1" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
|
|
||||||
第一章
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="2" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
|
|
||||||
第二章
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="3" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
|
|
||||||
第三章 (默认)
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid w-full gap-1.5">
|
|
||||||
<Label htmlFor="message" className="text-gray-300">
|
|
||||||
引流文案
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="输入引导用户加群的文案..."
|
|
||||||
id="message"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
defaultValue="阅读更多精彩内容,请加入Soul创业实验派对群..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button className="bg-[#38bdac] hover:bg-[#2da396] text-white">保存配置</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { usePathname } from "next/navigation"
|
|
||||||
import { LayoutDashboard, FileText, Users, CreditCard, QrCode, Settings, LogOut, Wallet } from "lucide-react"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { useEffect } from "react"
|
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const router = useRouter()
|
|
||||||
const { user, isLoggedIn } = useStore()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
// router.push("/my")
|
|
||||||
}
|
|
||||||
}, [isLoggedIn, router])
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{ icon: LayoutDashboard, label: "数据概览", href: "/admin" },
|
|
||||||
{ icon: FileText, label: "内容管理", href: "/admin/content" },
|
|
||||||
{ icon: Users, label: "用户管理", href: "/admin/users" },
|
|
||||||
{ icon: CreditCard, label: "支付配置", href: "/admin/payment" },
|
|
||||||
{ icon: Wallet, label: "提现管理", href: "/admin/withdrawals" },
|
|
||||||
{ icon: QrCode, label: "二维码", href: "/admin/qrcodes" },
|
|
||||||
{ icon: Settings, label: "系统设置", href: "/admin/settings" },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen bg-[#0a1628]">
|
|
||||||
{/* Sidebar - 深色侧边栏 */}
|
|
||||||
<div className="w-64 bg-[#0f2137] flex flex-col border-r border-gray-700/50 shadow-xl">
|
|
||||||
<div className="p-6 border-b border-gray-700/50">
|
|
||||||
<h1 className="text-xl font-bold text-[#38bdac]">管理后台</h1>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Soul创业实验场</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 p-4 space-y-1">
|
|
||||||
{menuItems.map((item) => {
|
|
||||||
const isActive = pathname === item.href
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
|
||||||
isActive
|
|
||||||
? "bg-[#38bdac]/20 text-[#38bdac] font-medium"
|
|
||||||
: "text-gray-400 hover:bg-gray-700/50 hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<item.icon className="w-5 h-5" />
|
|
||||||
<span className="text-sm">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="p-4 border-t border-gray-700/50">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-400 hover:text-white rounded-lg hover:bg-gray-700/50 transition-colors"
|
|
||||||
>
|
|
||||||
<LogOut className="w-5 h-5" />
|
|
||||||
<span className="text-sm">返回前台</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content - 深色背景 */}
|
|
||||||
<div className="flex-1 overflow-auto bg-[#0a1628]">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { Lock, User, ShieldCheck } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const { adminLogin } = useStore()
|
|
||||||
const [username, setUsername] = useState("")
|
|
||||||
const [password, setPassword] = useState("")
|
|
||||||
const [error, setError] = useState("")
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
setError("")
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
const success = adminLogin(username, password)
|
|
||||||
if (success) {
|
|
||||||
router.push("/admin")
|
|
||||||
} else {
|
|
||||||
setError("用户名或密码错误")
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0a1628] flex items-center justify-center p-4">
|
|
||||||
{/* 装饰背景 */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
|
||||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-[#38bdac]/5 rounded-full blur-3xl" />
|
|
||||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-blue-500/5 rounded-full blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full max-w-md relative z-10">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="w-16 h-16 bg-[#38bdac]/20 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-[#38bdac]/30">
|
|
||||||
<ShieldCheck className="w-8 h-8 text-[#38bdac]" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-white mb-2">管理后台</h1>
|
|
||||||
<p className="text-gray-400">一场SOUL的创业实验场</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Login form */}
|
|
||||||
<div className="bg-[#0f2137] rounded-2xl p-8 shadow-xl border border-gray-700/50 backdrop-blur-xl">
|
|
||||||
<h2 className="text-xl font-semibold text-white mb-6 text-center">管理员登录</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">用户名</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 focus:border-[#38bdac]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">密码</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="请输入密码"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 focus:border-[#38bdac]"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-500/10 text-red-400 text-sm p-3 rounded-lg border border-red-500/20">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleLogin}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? "登录中..." : "登录"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-gray-700/50">
|
|
||||||
<p className="text-gray-500 text-xs text-center">
|
|
||||||
默认账号: <span className="text-gray-300 font-mono">admin</span> /{" "}
|
|
||||||
<span className="text-gray-300 font-mono">key123456</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<p className="text-center text-gray-500 text-xs mt-6">Soul创业实验场 · 后台管理系统</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { Users, BookOpen, ShoppingBag, TrendingUp } from "lucide-react"
|
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
|
||||||
const { getAllUsers, getAllPurchases } = useStore()
|
|
||||||
const users = getAllUsers()
|
|
||||||
const purchases = getAllPurchases()
|
|
||||||
|
|
||||||
const totalRevenue = purchases.reduce((sum, p) => sum + p.amount, 0)
|
|
||||||
const totalUsers = users.length
|
|
||||||
const totalPurchases = purchases.length
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20" },
|
|
||||||
{
|
|
||||||
title: "总收入",
|
|
||||||
value: `¥${totalRevenue.toFixed(2)}`,
|
|
||||||
icon: TrendingUp,
|
|
||||||
color: "text-[#38bdac]",
|
|
||||||
bg: "bg-[#38bdac]/20",
|
|
||||||
},
|
|
||||||
{ title: "订单数", value: totalPurchases, icon: ShoppingBag, color: "text-purple-400", bg: "bg-purple-500/20" },
|
|
||||||
{
|
|
||||||
title: "转化率",
|
|
||||||
value: `${totalUsers > 0 ? ((totalPurchases / totalUsers) * 100).toFixed(1) : 0}%`,
|
|
||||||
icon: BookOpen,
|
|
||||||
color: "text-orange-400",
|
|
||||||
bg: "bg-orange-500/20",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-7xl mx-auto">
|
|
||||||
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
||||||
{stats.map((stat, index) => (
|
|
||||||
<Card key={index} className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-gray-400">{stat.title}</CardTitle>
|
|
||||||
<div className={`p-2 rounded-lg ${stat.bg}`}>
|
|
||||||
<stat.icon className={`w-4 h-4 ${stat.color}`} />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-white">{stat.value}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white">最近订单</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{purchases
|
|
||||||
.slice(-5)
|
|
||||||
.reverse()
|
|
||||||
.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white">{p.sectionTitle || "整本购买"}</p>
|
|
||||||
<p className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm font-bold text-[#38bdac]">+¥{p.amount}</p>
|
|
||||||
<p className="text-xs text-gray-400">{p.paymentMethod || "微信支付"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{purchases.length === 0 && <p className="text-gray-500 text-center py-8">暂无订单数据</p>}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white">新注册用户</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{users
|
|
||||||
.slice(-5)
|
|
||||||
.reverse()
|
|
||||||
.map((u) => (
|
|
||||||
<div
|
|
||||||
key={u.id}
|
|
||||||
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
|
||||||
{u.nickname.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white">{u.nickname}</p>
|
|
||||||
<p className="text-xs text-gray-500">{u.phone}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400">{new Date(u.createdAt).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{users.length === 0 && <p className="text-gray-500 text-center py-8">暂无用户数据</p>}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import {
|
|
||||||
Save,
|
|
||||||
RefreshCw,
|
|
||||||
Smartphone,
|
|
||||||
CreditCard,
|
|
||||||
ExternalLink,
|
|
||||||
Bitcoin,
|
|
||||||
Globe,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
HelpCircle,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
export default function PaymentConfigPage() {
|
|
||||||
const { settings, updateSettings, fetchSettings } = useStore()
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [localSettings, setLocalSettings] = useState(settings.paymentMethods)
|
|
||||||
const [copied, setCopied] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalSettings(settings.paymentMethods)
|
|
||||||
}, [settings.paymentMethods])
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
updateSettings({ paymentMethods: localSettings })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
|
||||||
setLoading(false)
|
|
||||||
alert("配置已保存!")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
await fetchSettings()
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCopy = (text: string, field: string) => {
|
|
||||||
navigator.clipboard.writeText(text)
|
|
||||||
setCopied(field)
|
|
||||||
setTimeout(() => setCopied(""), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateWechat = (field: string, value: any) => {
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
wechat: { ...prev.wechat, [field]: value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateAlipay = (field: string, value: any) => {
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
alipay: { ...prev.alipay, [field]: value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateUsdt = (field: string, value: any) => {
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
usdt: { ...prev.usdt, [field]: value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePaypal = (field: string, value: any) => {
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
paypal: { ...prev.paypal, [field]: value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-5xl mx-auto">
|
|
||||||
<div className="flex justify-between items-center mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold mb-2 text-white">支付配置</h1>
|
|
||||||
<p className="text-gray-400">配置微信、支付宝、USDT、PayPal等支付参数</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
|
||||||
同步配置
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
保存配置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6 bg-[#07C160]/10 border border-[#07C160]/30 rounded-xl p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<HelpCircle className="w-5 h-5 text-[#07C160] flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="text-sm">
|
|
||||||
<p className="font-medium mb-2 text-[#07C160]">如何获取微信群跳转链接?</p>
|
|
||||||
<ol className="text-[#07C160]/80 space-y-1 list-decimal list-inside">
|
|
||||||
<li>打开微信,进入目标微信群</li>
|
|
||||||
<li>点击右上角"..." → "群二维码"</li>
|
|
||||||
<li>点击右上角"..." → "发送到电脑"</li>
|
|
||||||
<li>在电脑上保存二维码图片,上传到图床获取URL</li>
|
|
||||||
<li>或使用草料二维码等工具解析二维码获取链接</li>
|
|
||||||
</ol>
|
|
||||||
<p className="text-[#07C160]/60 mt-2">提示:微信群二维码7天后失效,建议使用活码工具</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="wechat" className="space-y-6">
|
|
||||||
<TabsList className="bg-[#0f2137] border border-gray-700/50 p-1 grid grid-cols-4 w-full">
|
|
||||||
<TabsTrigger
|
|
||||||
value="wechat"
|
|
||||||
className="data-[state=active]:bg-[#07C160]/20 data-[state=active]:text-[#07C160] text-gray-400"
|
|
||||||
>
|
|
||||||
<Smartphone className="w-4 h-4 mr-2" />
|
|
||||||
微信
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="alipay"
|
|
||||||
className="data-[state=active]:bg-[#1677FF]/20 data-[state=active]:text-[#1677FF] text-gray-400"
|
|
||||||
>
|
|
||||||
<CreditCard className="w-4 h-4 mr-2" />
|
|
||||||
支付宝
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="usdt"
|
|
||||||
className="data-[state=active]:bg-[#26A17B]/20 data-[state=active]:text-[#26A17B] text-gray-400"
|
|
||||||
>
|
|
||||||
<Bitcoin className="w-4 h-4 mr-2" />
|
|
||||||
USDT
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="paypal"
|
|
||||||
className="data-[state=active]:bg-[#003087]/20 data-[state=active]:text-[#169BD7] text-gray-400"
|
|
||||||
>
|
|
||||||
<Globe className="w-4 h-4 mr-2" />
|
|
||||||
PayPal
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* 微信支付配置 */}
|
|
||||||
<TabsContent value="wechat" className="space-y-4">
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle className="text-[#07C160] flex items-center gap-2">
|
|
||||||
<Smartphone className="w-5 h-5" />
|
|
||||||
微信支付配置
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">配置微信支付参数和跳转链接</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Switch checked={localSettings.wechat.enabled} onCheckedChange={(c) => updateWechat("enabled", c)} />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* API配置 */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">网站AppID</Label>
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
|
||||||
value={localSettings.wechat.websiteAppId || ""}
|
|
||||||
onChange={(e) => updateWechat("websiteAppId", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">商户号</Label>
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
|
||||||
value={localSettings.wechat.merchantId || ""}
|
|
||||||
onChange={(e) => updateWechat("merchantId", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 跳转链接配置 - 重点 */}
|
|
||||||
<div className="border-t border-gray-700/50 pt-4 space-y-4">
|
|
||||||
<h4 className="text-white font-medium flex items-center gap-2">
|
|
||||||
<ExternalLink className="w-4 h-4 text-[#38bdac]" />
|
|
||||||
跳转链接配置(核心功能)
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">微信收款码/支付链接</Label>
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
placeholder="https://收款码图片URL 或 weixin://支付链接"
|
|
||||||
value={localSettings.wechat.qrCode || ""}
|
|
||||||
onChange={(e) => updateWechat("qrCode", e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">用户点击微信支付后显示的二维码图片URL</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 bg-[#07C160]/5 p-4 rounded-xl border border-[#07C160]/20">
|
|
||||||
<Label className="text-[#07C160] font-medium">微信群跳转链接(支付成功后跳转)</Label>
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-[#07C160]/30 text-white placeholder:text-gray-500"
|
|
||||||
placeholder="https://weixin.qq.com/g/... 或微信群二维码图片URL"
|
|
||||||
value={localSettings.wechat.groupQrCode || ""}
|
|
||||||
onChange={(e) => updateWechat("groupQrCode", e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[#07C160]/70">用户支付成功后将自动跳转到此链接,进入指定微信群</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 支付宝配置 */}
|
|
||||||
<TabsContent value="alipay" className="space-y-4">
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle className="text-[#1677FF] flex items-center gap-2">
|
|
||||||
<CreditCard className="w-5 h-5" />
|
|
||||||
支付宝配置
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">已加载真实支付宝参数</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Switch checked={localSettings.alipay.enabled} onCheckedChange={(c) => updateAlipay("enabled", c)} />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">合作者身份 (PID)</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
|
||||||
value={localSettings.alipay.partnerId || ""}
|
|
||||||
onChange={(e) => updateAlipay("partnerId", e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="border-gray-700 bg-transparent"
|
|
||||||
onClick={() => handleCopy(localSettings.alipay.partnerId || "", "pid")}
|
|
||||||
>
|
|
||||||
{copied === "pid" ? (
|
|
||||||
<Check className="w-4 h-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4 text-gray-400" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">安全校验码 (Key)</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
|
||||||
value={localSettings.alipay.securityKey || ""}
|
|
||||||
onChange={(e) => updateAlipay("securityKey", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-700/50 pt-4 space-y-4">
|
|
||||||
<h4 className="text-white font-medium flex items-center gap-2">
|
|
||||||
<ExternalLink className="w-4 h-4 text-[#38bdac]" />
|
|
||||||
跳转链接配置
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">支付宝收款码/跳转链接</Label>
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
placeholder="https://qr.alipay.com/... 或收款码图片URL"
|
|
||||||
value={localSettings.alipay.qrCode || ""}
|
|
||||||
onChange={(e) => updateAlipay("qrCode", e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">用户点击支付宝支付后显示的二维码</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* USDT配置 */}
|
|
||||||
<TabsContent value="usdt" className="space-y-4">
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle className="text-[#26A17B] flex items-center gap-2">
|
|
||||||
<Bitcoin className="w-5 h-5" />
|
|
||||||
USDT配置
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">配置加密货币收款地址</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Switch checked={localSettings.usdt.enabled} onCheckedChange={(c) => updateUsdt("enabled", c)} />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">网络类型</Label>
|
|
||||||
<select
|
|
||||||
className="w-full bg-[#0a1628] border border-gray-700 text-white rounded-md p-2"
|
|
||||||
value={localSettings.usdt.network}
|
|
||||||
onChange={(e) => updateUsdt("network", e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="TRC20">TRC20 (波场)</option>
|
|
||||||
<option value="ERC20">ERC20 (以太坊)</option>
|
|
||||||
<option value="BEP20">BEP20 (币安链)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">收款地址</Label>
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
|
||||||
placeholder="T... (TRC20地址)"
|
|
||||||
value={localSettings.usdt.address || ""}
|
|
||||||
onChange={(e) => updateUsdt("address", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">汇率 (1 USD = ? CNY)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white"
|
|
||||||
value={localSettings.usdt.exchangeRate}
|
|
||||||
onChange={(e) => updateUsdt("exchangeRate", Number.parseFloat(e.target.value) || 7.2)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* PayPal配置 */}
|
|
||||||
<TabsContent value="paypal" className="space-y-4">
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle className="text-[#169BD7] flex items-center gap-2">
|
|
||||||
<Globe className="w-5 h-5" />
|
|
||||||
PayPal配置
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">配置PayPal收款账户</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Switch checked={localSettings.paypal.enabled} onCheckedChange={(c) => updatePaypal("enabled", c)} />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">PayPal邮箱</Label>
|
|
||||||
<Input
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
value={localSettings.paypal.email || ""}
|
|
||||||
onChange={(e) => updatePaypal("email", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">汇率 (1 USD = ? CNY)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white"
|
|
||||||
value={localSettings.paypal.exchangeRate}
|
|
||||||
onChange={(e) => updatePaypal("exchangeRate", Number.parseFloat(e.target.value) || 7.2)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { QrCode, Upload, Link, ExternalLink, Copy, Check, HelpCircle } from "lucide-react"
|
|
||||||
|
|
||||||
export default function QRCodesPage() {
|
|
||||||
const { settings, updateSettings } = useStore()
|
|
||||||
const [liveQRUrls, setLiveQRUrls] = useState("")
|
|
||||||
const [wechatGroupUrl, setWechatGroupUrl] = useState("")
|
|
||||||
const [copied, setCopied] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLiveQRUrls(settings.liveQRCodes?.[0]?.urls?.join("\n") || "")
|
|
||||||
setWechatGroupUrl(settings.paymentMethods?.wechat?.groupQrCode || "")
|
|
||||||
}, [settings])
|
|
||||||
|
|
||||||
const handleCopy = (text: string, field: string) => {
|
|
||||||
navigator.clipboard.writeText(text)
|
|
||||||
setCopied(field)
|
|
||||||
setTimeout(() => setCopied(""), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveLiveQR = () => {
|
|
||||||
const urls = liveQRUrls
|
|
||||||
.split("\n")
|
|
||||||
.map((u) => u.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
const updatedLiveQRCodes = [...(settings.liveQRCodes || [])]
|
|
||||||
if (updatedLiveQRCodes[0]) {
|
|
||||||
updatedLiveQRCodes[0].urls = urls
|
|
||||||
} else {
|
|
||||||
updatedLiveQRCodes.push({ id: "live-1", name: "微信群活码", urls, clickCount: 0 })
|
|
||||||
}
|
|
||||||
updateSettings({ liveQRCodes: updatedLiveQRCodes })
|
|
||||||
alert("群活码配置已保存!")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveWechatGroup = () => {
|
|
||||||
updateSettings({
|
|
||||||
paymentMethods: {
|
|
||||||
...settings.paymentMethods,
|
|
||||||
wechat: {
|
|
||||||
...settings.paymentMethods.wechat,
|
|
||||||
groupQrCode: wechatGroupUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
alert("微信群链接已保存!用户支付成功后将自动跳转")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试跳转
|
|
||||||
const handleTestJump = () => {
|
|
||||||
if (wechatGroupUrl) {
|
|
||||||
window.open(wechatGroupUrl, "_blank")
|
|
||||||
} else {
|
|
||||||
alert("请先配置微信群链接")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-5xl mx-auto">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-white">微信群活码管理</h2>
|
|
||||||
<p className="text-gray-400 mt-1">配置微信群跳转链接,用户支付后自动跳转加群</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 使用说明 */}
|
|
||||||
<div className="mb-6 bg-[#07C160]/10 border border-[#07C160]/30 rounded-xl p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<HelpCircle className="w-5 h-5 text-[#07C160] flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="text-sm">
|
|
||||||
<p className="font-medium mb-2 text-[#07C160]">微信群活码配置指南</p>
|
|
||||||
<div className="text-[#07C160]/80 space-y-2">
|
|
||||||
<p className="font-medium">方法一:使用草料活码(推荐)</p>
|
|
||||||
<ol className="list-decimal list-inside space-y-1 pl-2">
|
|
||||||
<li>
|
|
||||||
访问{" "}
|
|
||||||
<a href="https://cli.im/url" target="_blank" className="underline" rel="noreferrer">
|
|
||||||
草料二维码
|
|
||||||
</a>{" "}
|
|
||||||
创建活码
|
|
||||||
</li>
|
|
||||||
<li>上传微信群二维码图片,生成永久链接</li>
|
|
||||||
<li>复制生成的短链接填入下方配置</li>
|
|
||||||
<li>群满后可直接在草料后台更换新群码,链接不变</li>
|
|
||||||
</ol>
|
|
||||||
<p className="font-medium mt-3">方法二:直接使用微信群链接</p>
|
|
||||||
<ol className="list-decimal list-inside space-y-1 pl-2">
|
|
||||||
<li>微信打开目标群 → 右上角"..." → 群二维码</li>
|
|
||||||
<li>长按二维码 → 识别二维码 → 复制链接</li>
|
|
||||||
<li>或使用第三方工具解析二维码获取链接</li>
|
|
||||||
</ol>
|
|
||||||
<p className="text-[#07C160]/60 mt-2">注意:微信原生群二维码7天后失效,建议使用草料活码</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
{/* 主要配置 - 支付后跳转 */}
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl md:col-span-2">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-[#07C160] flex items-center gap-2">
|
|
||||||
<QrCode className="w-5 h-5" />
|
|
||||||
支付成功跳转链接(核心配置)
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">用户支付完成后自动跳转到此链接,进入指定微信群</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="wechat-group-url" className="text-gray-300 flex items-center gap-2">
|
|
||||||
<Link className="w-4 h-4" />
|
|
||||||
微信群链接 / 活码链接
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="wechat-group-url"
|
|
||||||
placeholder="https://cli.im/xxxxx 或 https://weixin.qq.com/g/..."
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 flex-1"
|
|
||||||
value={wechatGroupUrl}
|
|
||||||
onChange={(e) => setWechatGroupUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="border-gray-700 bg-transparent hover:bg-gray-700/50"
|
|
||||||
onClick={() => handleCopy(wechatGroupUrl, "group")}
|
|
||||||
>
|
|
||||||
{copied === "group" ? (
|
|
||||||
<Check className="w-4 h-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4 text-gray-400" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
支持格式:草料短链、微信群链接(https://weixin.qq.com/g/...)、企业微信链接等
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button onClick={handleSaveWechatGroup} className="flex-1 bg-[#07C160] hover:bg-[#06AD51] text-white">
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
保存配置
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleTestJump}
|
|
||||||
variant="outline"
|
|
||||||
className="border-[#07C160] text-[#07C160] hover:bg-[#07C160]/10 bg-transparent"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
|
||||||
测试跳转
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 多群轮换配置 */}
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl md:col-span-2">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white flex items-center gap-2">
|
|
||||||
<QrCode className="w-5 h-5 text-[#38bdac]" />
|
|
||||||
多群轮换(高级配置)
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">配置多个群链接,系统自动轮换分配,避免单群满员</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="group-urls" className="text-gray-300 flex items-center gap-2">
|
|
||||||
<Link className="w-4 h-4" />
|
|
||||||
多个群链接(每行一个)
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="group-urls"
|
|
||||||
placeholder={`https://cli.im/group1\nhttps://cli.im/group2\nhttps://cli.im/group3`}
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 min-h-[120px] font-mono text-sm"
|
|
||||||
value={liveQRUrls}
|
|
||||||
onChange={(e) => setLiveQRUrls(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">每行填写一个群链接,系统将按顺序或随机分配</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-3 bg-[#0a1628] rounded-lg border border-gray-700/50">
|
|
||||||
<span className="text-sm text-gray-400">已配置群数量</span>
|
|
||||||
<span className="font-bold text-[#38bdac]">{liveQRUrls.split("\n").filter(Boolean).length} 个</span>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSaveLiveQR} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white">
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
保存多群配置
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 常见问题 */}
|
|
||||||
<div className="mt-6 bg-[#0f2137] rounded-xl p-4 border border-gray-700/50">
|
|
||||||
<h4 className="text-white font-medium mb-3">常见问题</h4>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="text-[#38bdac]">Q: 为什么推荐使用草料活码?</p>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
A: 草料活码是永久链接,群满后可直接在后台更换新群码,无需修改网站配置。微信原生群码7天失效。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[#38bdac]">Q: 支付后没有跳转怎么办?</p>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
A: 1) 检查链接是否正确填写 2) 部分浏览器可能拦截弹窗,用户需手动允许 3) 建议使用https开头的链接
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[#38bdac]">Q: 如何获取企业微信群链接?</p>
|
|
||||||
<p className="text-gray-400">A: 企业微信后台 → 客户联系 → 加入群聊 → 获取永久有效的群二维码链接</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Slider } from "@/components/ui/slider"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { Save, Settings, Users, DollarSign } from "lucide-react"
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const { settings, updateSettings } = useStore()
|
|
||||||
const [localSettings, setLocalSettings] = useState({
|
|
||||||
sectionPrice: settings.sectionPrice,
|
|
||||||
baseBookPrice: settings.baseBookPrice,
|
|
||||||
distributorShare: settings.distributorShare,
|
|
||||||
authorInfo: settings.authorInfo,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalSettings({
|
|
||||||
sectionPrice: settings.sectionPrice,
|
|
||||||
baseBookPrice: settings.baseBookPrice,
|
|
||||||
distributorShare: settings.distributorShare,
|
|
||||||
authorInfo: settings.authorInfo,
|
|
||||||
})
|
|
||||||
}, [settings])
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
updateSettings(localSettings)
|
|
||||||
alert("设置已保存!")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-4xl mx-auto">
|
|
||||||
<div className="flex justify-between items-center mb-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-white">系统设置</h2>
|
|
||||||
<p className="text-gray-400 mt-1">配置全站基础参数与开关</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSave} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
保存设置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 基础信息 */}
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white flex items-center gap-2">
|
|
||||||
<Settings className="w-5 h-5 text-[#38bdac]" />
|
|
||||||
基础信息
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">网站显示的基本信息配置</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="author-name" className="text-gray-300">
|
|
||||||
主理人名称
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="author-name"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white"
|
|
||||||
value={localSettings.authorInfo.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
authorInfo: { ...prev.authorInfo, name: e.target.value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="live-time" className="text-gray-300">
|
|
||||||
直播时间
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="live-time"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white"
|
|
||||||
value={localSettings.authorInfo.liveTime}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
authorInfo: { ...prev.authorInfo, liveTime: e.target.value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description" className="text-gray-300">
|
|
||||||
简介描述
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white"
|
|
||||||
value={localSettings.authorInfo.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
authorInfo: { ...prev.authorInfo, description: e.target.value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 价格设置 */}
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white flex items-center gap-2">
|
|
||||||
<DollarSign className="w-5 h-5 text-[#38bdac]" />
|
|
||||||
价格设置
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">配置书籍和章节的定价</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">单节价格 (元)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white"
|
|
||||||
value={localSettings.sectionPrice}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
sectionPrice: Number.parseFloat(e.target.value) || 1,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-gray-300">整本价格 (元)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white"
|
|
||||||
value={localSettings.baseBookPrice}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
baseBookPrice: Number.parseFloat(e.target.value) || 9.9,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 分销设置 */}
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white flex items-center gap-2">
|
|
||||||
<Users className="w-5 h-5 text-[#38bdac]" />
|
|
||||||
分销设置
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">配置分销比例和奖励规则</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Label className="text-gray-300">分销者分成比例</Label>
|
|
||||||
<span className="text-2xl font-bold text-[#38bdac]">{localSettings.distributorShare}%</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[localSettings.distributorShare]}
|
|
||||||
onValueChange={([value]) =>
|
|
||||||
setLocalSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
distributorShare: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-sm text-gray-400">
|
|
||||||
<span>作者获得: {100 - localSettings.distributorShare}%</span>
|
|
||||||
<span>分销者获得: {localSettings.distributorShare}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 功能开关 */}
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white">功能开关</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">控制系统核心模块的启用状态</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="maintenance-mode" className="flex flex-col space-y-1">
|
|
||||||
<span className="text-white">维护模式</span>
|
|
||||||
<span className="font-normal text-xs text-gray-500">启用后前台将显示维护中页面</span>
|
|
||||||
</Label>
|
|
||||||
<Switch id="maintenance-mode" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="payment-enabled" className="flex flex-col space-y-1">
|
|
||||||
<span className="text-white">全站支付</span>
|
|
||||||
<span className="font-normal text-xs text-gray-500">关闭后所有支付功能将暂停</span>
|
|
||||||
</Label>
|
|
||||||
<Switch id="payment-enabled" defaultChecked />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="referral-enabled" className="flex flex-col space-y-1">
|
|
||||||
<span className="text-white">分销系统</span>
|
|
||||||
<span className="font-normal text-xs text-gray-500">是否允许用户生成邀请链接</span>
|
|
||||||
</Label>
|
|
||||||
<Switch id="referral-enabled" defaultChecked />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from "react"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { useStore, type User } from "@/lib/store"
|
|
||||||
import { Search, UserPlus, Eye, Trash2 } from "lucide-react"
|
|
||||||
|
|
||||||
function UsersContent() {
|
|
||||||
const { getAllUsers, deleteUser } = useStore()
|
|
||||||
const [users, setUsers] = useState<User[]>([])
|
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUsers(getAllUsers())
|
|
||||||
}, [getAllUsers])
|
|
||||||
|
|
||||||
const filteredUsers = users.filter((u) => u.nickname.includes(searchTerm) || u.phone.includes(searchTerm))
|
|
||||||
|
|
||||||
const handleDelete = (userId: string) => {
|
|
||||||
if (confirm("确定要删除这个用户吗?")) {
|
|
||||||
deleteUser(userId)
|
|
||||||
setUsers(getAllUsers())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-7xl mx-auto">
|
|
||||||
<div className="flex justify-between items-center mb-8">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-white">用户管理</h2>
|
|
||||||
<p className="text-gray-400 mt-1">共 {users.length} 位注册用户</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索用户..."
|
|
||||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500 w-64"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
|
||||||
添加用户
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
|
||||||
<TableHead className="text-gray-400">用户信息</TableHead>
|
|
||||||
<TableHead className="text-gray-400">手机号</TableHead>
|
|
||||||
<TableHead className="text-gray-400">购买状态</TableHead>
|
|
||||||
<TableHead className="text-gray-400">分销收益</TableHead>
|
|
||||||
<TableHead className="text-gray-400">注册时间</TableHead>
|
|
||||||
<TableHead className="text-right text-gray-400">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredUsers.map((user) => (
|
|
||||||
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
|
||||||
{user.nickname.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-white">{user.nickname}</p>
|
|
||||||
<p className="text-xs text-gray-500">ID: {user.id.slice(0, 8)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-gray-300">{user.phone}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{user.hasFullBook ? (
|
|
||||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">全书已购</Badge>
|
|
||||||
) : user.purchasedSections.length > 0 ? (
|
|
||||||
<Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">
|
|
||||||
已购 {user.purchasedSections.length} 节
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-gray-500 border-gray-600">
|
|
||||||
未购买
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-white font-medium">¥{user.earnings?.toFixed(2) || "0.00"}</TableCell>
|
|
||||||
<TableCell className="text-gray-400">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-white hover:bg-gray-700/50">
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
onClick={() => handleDelete(user.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{filteredUsers.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
|
|
||||||
暂无用户数据
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<UsersContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Check, Clock, Wallet, History } from "lucide-react"
|
|
||||||
|
|
||||||
export default function WithdrawalsPage() {
|
|
||||||
const { withdrawals, completeWithdrawal } = useStore()
|
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!mounted) return null
|
|
||||||
|
|
||||||
const pendingWithdrawals = withdrawals?.filter((w) => w.status === "pending") || []
|
|
||||||
const historyWithdrawals =
|
|
||||||
withdrawals
|
|
||||||
?.filter((w) => w.status !== "pending")
|
|
||||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) || []
|
|
||||||
|
|
||||||
const totalPending = pendingWithdrawals.reduce((sum, w) => sum + w.amount, 0)
|
|
||||||
|
|
||||||
const handleApprove = (id: string) => {
|
|
||||||
if (confirm("确认打款并完成此提现申请吗?")) {
|
|
||||||
completeWithdrawal(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-6xl mx-auto">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-white">提现管理</h1>
|
|
||||||
<p className="text-gray-400 mt-1">
|
|
||||||
待处理 {pendingWithdrawals.length} 笔,共 ¥{totalPending.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6">
|
|
||||||
{/* 待处理申请 */}
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-white">
|
|
||||||
<div className="p-2 rounded-lg bg-orange-500/20">
|
|
||||||
<Clock className="w-5 h-5 text-orange-400" />
|
|
||||||
</div>
|
|
||||||
待处理申请 ({pendingWithdrawals.length})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{pendingWithdrawals.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<Wallet className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-500">暂无待处理申请</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[#0a1628] text-gray-400">
|
|
||||||
<th className="p-4 text-left font-medium">申请时间</th>
|
|
||||||
<th className="p-4 text-left font-medium">用户</th>
|
|
||||||
<th className="p-4 text-left font-medium">收款方式</th>
|
|
||||||
<th className="p-4 text-left font-medium">收款账号</th>
|
|
||||||
<th className="p-4 text-left font-medium">金额</th>
|
|
||||||
<th className="p-4 text-right font-medium">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-700/50">
|
|
||||||
{pendingWithdrawals.map((w) => (
|
|
||||||
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors">
|
|
||||||
<td className="p-4 text-gray-400">{new Date(w.createdAt).toLocaleString()}</td>
|
|
||||||
<td className="p-4">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-white">{w.name}</p>
|
|
||||||
<p className="text-xs text-gray-500 font-mono">{w.userId.slice(0, 8)}...</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-4">
|
|
||||||
<Badge
|
|
||||||
className={
|
|
||||||
w.method === "wechat"
|
|
||||||
? "bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"
|
|
||||||
: "bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{w.method === "wechat" ? "微信" : "支付宝"}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="p-4 font-mono text-gray-300">{w.account}</td>
|
|
||||||
<td className="p-4">
|
|
||||||
<span className="font-bold text-orange-400">¥{w.amount.toFixed(2)}</span>
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-right">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleApprove(w.id)}
|
|
||||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4 mr-1" />
|
|
||||||
确认打款
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 处理历史 */}
|
|
||||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-white">
|
|
||||||
<div className="p-2 rounded-lg bg-gray-700/50">
|
|
||||||
<History className="w-5 h-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
处理历史
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{historyWithdrawals.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<History className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-500">暂无历史记录</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[#0a1628] text-gray-400">
|
|
||||||
<th className="p-4 text-left font-medium">申请时间</th>
|
|
||||||
<th className="p-4 text-left font-medium">处理时间</th>
|
|
||||||
<th className="p-4 text-left font-medium">用户</th>
|
|
||||||
<th className="p-4 text-left font-medium">渠道</th>
|
|
||||||
<th className="p-4 text-left font-medium">金额</th>
|
|
||||||
<th className="p-4 text-left font-medium">状态</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-700/50">
|
|
||||||
{historyWithdrawals.map((w) => (
|
|
||||||
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors">
|
|
||||||
<td className="p-4 text-gray-400">{new Date(w.createdAt).toLocaleString()}</td>
|
|
||||||
<td className="p-4 text-gray-400">
|
|
||||||
{w.completedAt ? new Date(w.completedAt).toLocaleString() : "-"}
|
|
||||||
</td>
|
|
||||||
<td className="p-4 font-medium text-white">{w.name}</td>
|
|
||||||
<td className="p-4 text-gray-300">{w.method === "wechat" ? "微信" : "支付宝"}</td>
|
|
||||||
<td className="p-4 font-medium text-white">¥{w.amount.toFixed(2)}</td>
|
|
||||||
<td className="p-4">
|
|
||||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
|
|
||||||
已完成
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const config = {
|
|
||||||
paymentMethods: {
|
|
||||||
wechat: {
|
|
||||||
enabled: true,
|
|
||||||
qrCode: "/images/wechat-pay.png",
|
|
||||||
account: "卡若",
|
|
||||||
appId: process.env.TENCENT_APP_ID || "1251077262", // From .env or default
|
|
||||||
// 敏感信息后端处理,不完全暴露给前端
|
|
||||||
},
|
|
||||||
alipay: {
|
|
||||||
enabled: true,
|
|
||||||
qrCode: "/images/alipay.png",
|
|
||||||
account: "卡若",
|
|
||||||
appId: process.env.ALIPAY_ACCESS_KEY_ID || "LTAI5t9zkiWmFtHG8qmtdysW", // Using Access Key as placeholder ID
|
|
||||||
},
|
|
||||||
usdt: {
|
|
||||||
enabled: true,
|
|
||||||
network: "TRC20",
|
|
||||||
address: process.env.USDT_WALLET_ADDRESS || "TWeq9xxxxxxxxxxxxxxxxxxxx",
|
|
||||||
exchangeRate: 7.2
|
|
||||||
},
|
|
||||||
paypal: {
|
|
||||||
enabled: false,
|
|
||||||
email: process.env.PAYPAL_CLIENT_ID || "",
|
|
||||||
exchangeRate: 7.2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
marketing: {
|
|
||||||
partyGroup: {
|
|
||||||
url: "https://soul.cn/party",
|
|
||||||
liveCodeUrl: "https://soul.cn/party-live",
|
|
||||||
qrCode: "/images/party-group-qr.png"
|
|
||||||
},
|
|
||||||
banner: {
|
|
||||||
text: "每日早上6-9点,Soul派对房不见不散",
|
|
||||||
visible: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
authorInfo: {
|
|
||||||
name: "卡若",
|
|
||||||
description: "私域运营与技术公司主理人",
|
|
||||||
liveTime: "06:00-09:00",
|
|
||||||
platform: "Soul"
|
|
||||||
},
|
|
||||||
system: {
|
|
||||||
version: "1.0.0",
|
|
||||||
maintenance: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(config);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server"
|
|
||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const searchParams = request.nextUrl.searchParams
|
|
||||||
const filePath = searchParams.get("path")
|
|
||||||
|
|
||||||
if (!filePath) {
|
|
||||||
return NextResponse.json({ error: "Path is required" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filePath.startsWith("custom/")) {
|
|
||||||
return NextResponse.json({ content: "", isCustom: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const normalizedPath = filePath.replace(/^\/+/, "")
|
|
||||||
const fullPath = path.join(process.cwd(), normalizedPath)
|
|
||||||
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
return NextResponse.json({ error: "File not found" }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = fs.statSync(fullPath)
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
return NextResponse.json({ error: "Path is a directory" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = fs.readFileSync(fullPath, "utf-8")
|
|
||||||
return NextResponse.json({ content, isCustom: false })
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[v0] Error reading file:", error)
|
|
||||||
return NextResponse.json({ error: "Failed to read file" }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server"
|
|
||||||
import { getDocumentationCatalog } from "@/lib/documentation/catalog"
|
|
||||||
import { captureScreenshots } from "@/lib/documentation/screenshot"
|
|
||||||
import { renderDocumentationDocx } from "@/lib/documentation/docx"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
|
||||||
|
|
||||||
function getBaseUrl(request: NextRequest) {
|
|
||||||
const proto = request.headers.get("x-forwarded-proto") || "http"
|
|
||||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host")
|
|
||||||
if (!host) return null
|
|
||||||
return `${proto}://${host}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAuthorized(request: NextRequest) {
|
|
||||||
const token = process.env.DOCUMENTATION_TOKEN
|
|
||||||
// If no token is configured, allow access (internal tool)
|
|
||||||
if (!token || token === "") return true
|
|
||||||
const header = request.headers.get("x-documentation-token")
|
|
||||||
const query = request.nextUrl.searchParams.get("token")
|
|
||||||
return header === token || query === token
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
if (!isAuthorized(request)) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getBaseUrl(request)
|
|
||||||
if (!baseUrl) {
|
|
||||||
return NextResponse.json({ error: "Host is required" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pages = getDocumentationCatalog()
|
|
||||||
const screenshots = await captureScreenshots(pages, {
|
|
||||||
baseUrl,
|
|
||||||
timeoutMs: 60000,
|
|
||||||
viewport: { width: 430, height: 932 },
|
|
||||||
})
|
|
||||||
const docxBuffer = await renderDocumentationDocx(screenshots)
|
|
||||||
|
|
||||||
return new NextResponse(docxBuffer, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
"Content-Disposition": `attachment; filename="app-documentation.docx"`,
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import { getBookStructure } from "@/lib/book-file-system"
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const structure = getBookStructure()
|
|
||||||
return NextResponse.json(structure)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error generating menu:", error)
|
|
||||||
return NextResponse.json({ error: "Failed to generate menu" }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server"
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const userId = searchParams.get("userId")
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ code: 400, message: "缺少用户ID" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// In production, fetch from database
|
|
||||||
// For now, return mock data
|
|
||||||
const orders = []
|
|
||||||
|
|
||||||
console.log("[v0] Fetching orders for user:", userId)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 0,
|
|
||||||
message: "获取成功",
|
|
||||||
data: orders,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[v0] Get orders error:", error)
|
|
||||||
return NextResponse.json({ code: 500, message: "服务器错误" }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server"
|
|
||||||
import { AlipayService } from "@/lib/payment/alipay"
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const formData = await request.formData()
|
|
||||||
const params: Record<string, string> = {}
|
|
||||||
|
|
||||||
formData.forEach((value, key) => {
|
|
||||||
params[key] = value.toString()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 初始化支付宝服务
|
|
||||||
const alipay = new AlipayService({
|
|
||||||
appId: process.env.ALIPAY_APP_ID || "wx432c93e275548671",
|
|
||||||
partnerId: process.env.ALIPAY_PARTNER_ID || "2088511801157159",
|
|
||||||
key: process.env.ALIPAY_KEY || "lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp",
|
|
||||||
returnUrl: "",
|
|
||||||
notifyUrl: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 验证签名
|
|
||||||
const isValid = alipay.verifySign(params)
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
console.error("[v0] Alipay signature verification failed")
|
|
||||||
return new NextResponse("fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
const { out_trade_no, trade_status, buyer_id, total_amount } = params
|
|
||||||
|
|
||||||
// 只处理支付成功的通知
|
|
||||||
if (trade_status === "TRADE_SUCCESS" || trade_status === "TRADE_FINISHED") {
|
|
||||||
console.log("[v0] Alipay payment success:", {
|
|
||||||
orderId: out_trade_no,
|
|
||||||
amount: total_amount,
|
|
||||||
buyerId: buyer_id,
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO: 更新订单状态、解锁内容、分配佣金
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse("success")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[v0] Alipay notify error:", error)
|
|
||||||
return new NextResponse("fail")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server"
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { orderId, status, transactionId, amount, paymentMethod, signature } = body
|
|
||||||
|
|
||||||
console.log("[v0] Payment callback received:", {
|
|
||||||
orderId,
|
|
||||||
status,
|
|
||||||
transactionId,
|
|
||||||
amount,
|
|
||||||
paymentMethod,
|
|
||||||
})
|
|
||||||
|
|
||||||
// In production:
|
|
||||||
// 1. Verify signature from payment gateway
|
|
||||||
// 2. Update order status in database
|
|
||||||
// 3. Grant user access to content
|
|
||||||
// 4. Calculate and distribute referral commission
|
|
||||||
|
|
||||||
// Mock signature verification
|
|
||||||
// const isValid = verifySignature(body, signature)
|
|
||||||
|
|
||||||
// For now, accept all callbacks
|
|
||||||
const isValid = true
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return NextResponse.json({ code: 403, message: "签名验证失败" }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update order status
|
|
||||||
if (status === "success") {
|
|
||||||
// Grant access
|
|
||||||
console.log("[v0] Payment successful, granting access for order:", orderId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 0,
|
|
||||||
message: "回调处理成功",
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[v0] Payment callback error:", error)
|
|
||||||
return NextResponse.json({ code: 500, message: "服务器错误" }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server"
|
|
||||||
import { AlipayService } from "@/lib/payment/alipay"
|
|
||||||
import { WechatPayService } from "@/lib/payment/wechat"
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { userId, type, sectionId, sectionTitle, amount, paymentMethod, referralCode } = body
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!userId || !type || !amount || !paymentMethod) {
|
|
||||||
return NextResponse.json({ code: 400, message: "缺少必要参数" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate order ID
|
|
||||||
const orderId = `ORDER_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
|
||||||
|
|
||||||
// Create order object
|
|
||||||
const order = {
|
|
||||||
orderId,
|
|
||||||
userId,
|
|
||||||
type, // "section" | "fullbook"
|
|
||||||
sectionId: type === "section" ? sectionId : undefined,
|
|
||||||
sectionTitle: type === "section" ? sectionTitle : undefined,
|
|
||||||
amount,
|
|
||||||
paymentMethod, // "wechat" | "alipay" | "usdt" | "paypal"
|
|
||||||
referralCode,
|
|
||||||
status: "pending", // pending | completed | failed | refunded
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
expireAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 minutes
|
|
||||||
}
|
|
||||||
|
|
||||||
// According to the payment method, create a payment order
|
|
||||||
let paymentData = null
|
|
||||||
|
|
||||||
if (paymentMethod === "alipay") {
|
|
||||||
const alipay = new AlipayService({
|
|
||||||
appId: process.env.ALIPAY_APP_ID || "wx432c93e275548671",
|
|
||||||
partnerId: process.env.ALIPAY_PARTNER_ID || "2088511801157159",
|
|
||||||
key: process.env.ALIPAY_KEY || "lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp",
|
|
||||||
returnUrl: process.env.ALIPAY_RETURN_URL || `${process.env.NEXT_PUBLIC_BASE_URL}/payment/success`,
|
|
||||||
notifyUrl: process.env.ALIPAY_NOTIFY_URL || `${process.env.NEXT_PUBLIC_BASE_URL}/api/payment/alipay/notify`,
|
|
||||||
})
|
|
||||||
|
|
||||||
paymentData = alipay.createOrder({
|
|
||||||
outTradeNo: orderId,
|
|
||||||
subject: type === "section" ? `购买章节: ${sectionTitle}` : "购买整本书",
|
|
||||||
totalAmount: amount,
|
|
||||||
body: `知识付费-书籍购买`,
|
|
||||||
})
|
|
||||||
} else if (paymentMethod === "wechat") {
|
|
||||||
const wechat = new WechatPayService({
|
|
||||||
appId: process.env.WECHAT_APP_ID || "wx432c93e275548671",
|
|
||||||
appSecret: process.env.WECHAT_APP_SECRET || "25b7e7fdb7998e5107e242ebb6ddabd0",
|
|
||||||
mchId: process.env.WECHAT_MCH_ID || "1318592501",
|
|
||||||
apiKey: process.env.WECHAT_API_KEY || "wx3e31b068be59ddc131b068be59ddc2",
|
|
||||||
notifyUrl: process.env.WECHAT_NOTIFY_URL || `${process.env.NEXT_PUBLIC_BASE_URL}/api/payment/wechat/notify`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const clientIp = request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "127.0.0.1"
|
|
||||||
|
|
||||||
paymentData = await wechat.createOrder({
|
|
||||||
outTradeNo: orderId,
|
|
||||||
body: type === "section" ? `购买章节: ${sectionTitle}` : "购买整本书",
|
|
||||||
totalFee: amount,
|
|
||||||
spbillCreateIp: clientIp.split(",")[0].trim(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 0,
|
|
||||||
message: "订单创建成功",
|
|
||||||
data: {
|
|
||||||
...order,
|
|
||||||
paymentData,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[v0] Create order error:", error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
code: 500,
|
|
||||||
message: error instanceof Error ? error.message : "服务器错误",
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server"
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { orderId, paymentMethod, transactionId } = body
|
|
||||||
|
|
||||||
if (!orderId) {
|
|
||||||
return NextResponse.json({ code: 400, message: "缺少订单号" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// In production, verify with payment gateway API
|
|
||||||
// For now, simulate verification
|
|
||||||
console.log("[v0] Verifying payment:", { orderId, paymentMethod, transactionId })
|
|
||||||
|
|
||||||
// Simulate verification delay
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
// Mock verification result (95% success rate)
|
|
||||||
const isVerified = Math.random() > 0.05
|
|
||||||
|
|
||||||
if (isVerified) {
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 0,
|
|
||||||
message: "支付验证成功",
|
|
||||||
data: {
|
|
||||||
orderId,
|
|
||||||
status: "completed",
|
|
||||||
verifiedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return NextResponse.json({
|
|
||||||
code: 1,
|
|
||||||
message: "支付未完成,请稍后再试",
|
|
||||||
data: {
|
|
||||||
orderId,
|
|
||||||
status: "pending",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[v0] Verify payment error:", error)
|
|
||||||
return NextResponse.json({ code: 500, message: "服务器错误" }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server"
|
|
||||||
import { WechatPayService } from "@/lib/payment/wechat"
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const xmlData = await request.text()
|
|
||||||
|
|
||||||
const wechat = new WechatPayService({
|
|
||||||
appId: process.env.WECHAT_APP_ID || "wx432c93e275548671",
|
|
||||||
appSecret: process.env.WECHAT_APP_SECRET || "25b7e7fdb7998e5107e242ebb6ddabd0",
|
|
||||||
mchId: process.env.WECHAT_MCH_ID || "1318592501",
|
|
||||||
apiKey: process.env.WECHAT_API_KEY || "wx3e31b068be59ddc131b068be59ddc2",
|
|
||||||
notifyUrl: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 解析XML数据
|
|
||||||
const params = await wechat["parseXML"](xmlData)
|
|
||||||
|
|
||||||
// 验证签名
|
|
||||||
const isValid = wechat.verifySign(params)
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
console.error("[v0] WeChat signature verification failed")
|
|
||||||
return new NextResponse(
|
|
||||||
"<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[签名失败]]></return_msg></xml>",
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/xml" },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { out_trade_no, result_code, total_fee, openid } = params
|
|
||||||
|
|
||||||
if (result_code === "SUCCESS") {
|
|
||||||
console.log("[v0] WeChat payment success:", {
|
|
||||||
orderId: out_trade_no,
|
|
||||||
amount: Number.parseInt(total_fee) / 100,
|
|
||||||
openid,
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO: 更新订单状态、解锁内容、分配佣金
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(
|
|
||||||
"<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>",
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/xml" },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[v0] WeChat notify error:", error)
|
|
||||||
return new NextResponse(
|
|
||||||
"<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[系统错误]]></return_msg></xml>",
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/xml" },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import Link from "next/link"
|
|
||||||
import { ChevronLeft } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { specialSections, FULL_BOOK_PRICE } from "@/lib/book-data"
|
|
||||||
import { getBookStructure } from "@/lib/book-file-system"
|
|
||||||
import { ChaptersList } from "@/components/chapters-list"
|
|
||||||
|
|
||||||
import { BuyFullBookButton } from "@/components/buy-full-book-button"
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export default async function ChaptersPage() {
|
|
||||||
const parts = getBookStructure()
|
|
||||||
|
|
||||||
// Format special sections for the client component
|
|
||||||
const specialSectionsData = {
|
|
||||||
preface: { title: specialSections.preface.title },
|
|
||||||
epilogue: { title: specialSections.epilogue.title }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0a1628] text-white">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="sticky top-0 z-50 bg-[#0a1628]/90 backdrop-blur-md border-b border-gray-800">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
|
|
||||||
<Link href="/" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors">
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
<span>返回</span>
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-lg font-semibold">目录</h1>
|
|
||||||
<BuyFullBookButton size="sm" price={9.9} className="bg-[#38bdac] hover:bg-[#2da396] text-white" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
<ChaptersList parts={parts} specialSections={specialSectionsData} />
|
|
||||||
|
|
||||||
{/* Bottom CTA */}
|
|
||||||
<div className="mt-12 bg-gradient-to-r from-[#38bdac]/20 to-[#0f2137] rounded-2xl p-6 border border-[#38bdac]/30">
|
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white text-lg font-semibold mb-1">购买整本书,省82%</h3>
|
|
||||||
<p className="text-gray-400">全部55节内容,永久阅读,后续更新免费获取</p>
|
|
||||||
</div>
|
|
||||||
<BuyFullBookButton size="lg" price={9.9} className="bg-[#38bdac] hover:bg-[#2da396] text-white px-8">
|
|
||||||
立即购买 ¥9.9
|
|
||||||
</BuyFullBookButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { ArrowLeft, CreditCard, Share2, FileText, Code } from "lucide-react"
|
|
||||||
|
|
||||||
export default function DocsPage() {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[#0a1628] text-white pb-20">
|
|
||||||
<div className="sticky top-0 z-10 bg-[#0a1628]/95 backdrop-blur-md border-b border-gray-700/50">
|
|
||||||
<div className="max-w-2xl mx-auto flex items-center gap-4 p-4">
|
|
||||||
<Link href="/" className="p-2 -ml-2">
|
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-lg font-semibold">开发者文档</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
|
||||||
{/* Payment Configuration */}
|
|
||||||
<section className="bg-[#0f2137]/60 rounded-xl p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<CreditCard className="w-6 h-6 text-[#38bdac]" />
|
|
||||||
<h2 className="text-xl font-semibold">支付配置</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 text-gray-300 text-sm">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-medium mb-2">微信支付配置</h3>
|
|
||||||
<p className="mb-2">1. 登录微信开放平台获取网站AppID和AppSecret</p>
|
|
||||||
<p className="mb-2">2. 登录微信公众平台获取服务号AppID和AppSecret</p>
|
|
||||||
<p className="mb-2">3. 登录微信商户平台获取商户号和API密钥</p>
|
|
||||||
<div className="bg-[#0a1628] rounded-lg p-3 mt-2">
|
|
||||||
<code className="text-xs text-gray-400">
|
|
||||||
{`网站AppID: wx432c93e275548671
|
|
||||||
服务号AppID: wx7c0dbf34ddba300d
|
|
||||||
商户号: 1318592501`}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-medium mb-2">支付宝配置</h3>
|
|
||||||
<p className="mb-2">1. 登录支付宝开放平台获取合作者身份PID</p>
|
|
||||||
<p className="mb-2">2. 获取安全校验码Key</p>
|
|
||||||
<p className="mb-2">3. 开通手机网站支付功能</p>
|
|
||||||
<div className="bg-[#0a1628] rounded-lg p-3 mt-2">
|
|
||||||
<code className="text-xs text-gray-400">
|
|
||||||
{`合作者身份(PID): 2088511801157159
|
|
||||||
安全校验码(Key): lz6ey1h3kl9...`}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Distribution System */}
|
|
||||||
<section className="bg-[#0f2137]/60 rounded-xl p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<Share2 className="w-6 h-6 text-[#38bdac]" />
|
|
||||||
<h2 className="text-xl font-semibold">分销机制</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 text-gray-300 text-sm">
|
|
||||||
<p>分销比例可在后台自由设置(0-100%)</p>
|
|
||||||
<div className="bg-[#0a1628] rounded-lg p-4">
|
|
||||||
<p className="text-white mb-2">收益计算公式:</p>
|
|
||||||
<code className="text-[#38bdac]">分销收益 = 订单金额 × 分销比例%</code>
|
|
||||||
</div>
|
|
||||||
<p>例: 用户A通过B的邀请码购买¥9.9整本书,分销比例90%</p>
|
|
||||||
<p>则B获得 9.9 × 90% = ¥8.91 收益</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Withdrawal */}
|
|
||||||
<section className="bg-[#0f2137]/60 rounded-xl p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<FileText className="w-6 h-6 text-[#38bdac]" />
|
|
||||||
<h2 className="text-xl font-semibold">提现说明</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 text-gray-300 text-sm">
|
|
||||||
<p>1. 最低提现金额: ¥10</p>
|
|
||||||
<p>2. 提现周期: T+1到账</p>
|
|
||||||
<p>3. 支持提现方式: 微信、支付宝、银行卡</p>
|
|
||||||
<p>4. 提现手续费: 0%</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* API Reference */}
|
|
||||||
<section className="bg-[#0f2137]/60 rounded-xl p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<Code className="w-6 h-6 text-[#38bdac]" />
|
|
||||||
<h2 className="text-xl font-semibold">API接口</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 text-gray-300 text-sm">
|
|
||||||
<div className="bg-[#0a1628] rounded-lg p-4">
|
|
||||||
<p className="text-gray-400 mb-2">获取章节内容</p>
|
|
||||||
<code className="text-[#38bdac]">GET /api/content?id=1.1</code>
|
|
||||||
</div>
|
|
||||||
<div className="bg-[#0a1628] rounded-lg p-4">
|
|
||||||
<p className="text-gray-400 mb-2">飞书文档同步</p>
|
|
||||||
<code className="text-[#38bdac]">POST /api/feishu/sync</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function Loading() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import { useSearchParams } from "next/navigation"
|
|
||||||
|
|
||||||
export default function DocumentationCapturePage() {
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const path = searchParams.get("path") || "/"
|
|
||||||
const [loaded, setLoaded] = useState(false)
|
|
||||||
const [timeoutReached, setTimeoutReached] = useState(false)
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const src = useMemo(() => {
|
|
||||||
if (!path.startsWith("/")) return `/${path}`
|
|
||||||
return path
|
|
||||||
}, [path])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoaded(false)
|
|
||||||
setTimeoutReached(false)
|
|
||||||
setLoadError(null)
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
if (!loaded) {
|
|
||||||
setTimeoutReached(true)
|
|
||||||
}
|
|
||||||
}, 60000)
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timer)
|
|
||||||
}, [src, loaded])
|
|
||||||
|
|
||||||
const handleLoad = () => {
|
|
||||||
setLoaded(true)
|
|
||||||
setTimeoutReached(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleError = () => {
|
|
||||||
setLoadError("页面加载失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-white flex items-center justify-center">
|
|
||||||
<div className="w-[430px] h-[932px] border border-gray-200 bg-white relative overflow-hidden">
|
|
||||||
<iframe
|
|
||||||
data-doc-iframe="true"
|
|
||||||
data-loaded={loaded ? "true" : "false"}
|
|
||||||
src={src}
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
onLoad={handleLoad}
|
|
||||||
onError={handleError}
|
|
||||||
title={`Capture: ${path}`}
|
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!loaded && !timeoutReached && !loadError && (
|
|
||||||
<div className="absolute inset-0 bg-white flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-8 h-8 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-gray-500">加载中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(timeoutReached || loadError) && (
|
|
||||||
<div className="fixed left-0 top-0 right-0 bg-red-600 text-white text-sm px-3 py-2 text-center">
|
|
||||||
{loadError || "页面加载超时"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loaded && (
|
|
||||||
<div className="fixed left-0 bottom-0 right-0 bg-green-600 text-white text-xs px-3 py-1 text-center">
|
|
||||||
页面已加载: {path}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import { getDocumentationCatalog, type DocumentationPage } from "@/lib/documentation/catalog"
|
|
||||||
import { FileText, Download, Loader2, CheckCircle, XCircle, Eye, RefreshCw } from "lucide-react"
|
|
||||||
|
|
||||||
type PageStatus = "pending" | "loading" | "success" | "error"
|
|
||||||
|
|
||||||
type PageState = {
|
|
||||||
page: DocumentationPage
|
|
||||||
status: PageStatus
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocumentationToolPage() {
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [progress, setProgress] = useState(0)
|
|
||||||
const [currentPage, setCurrentPage] = useState<string | null>(null)
|
|
||||||
const [pageStates, setPageStates] = useState<PageState[]>([])
|
|
||||||
const [previewPath, setPreviewPath] = useState<string | null>(null)
|
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
||||||
|
|
||||||
const pages = useMemo(() => getDocumentationCatalog(), [])
|
|
||||||
|
|
||||||
const groupedPages = useMemo(() => {
|
|
||||||
const groups: Record<string, DocumentationPage[]> = {}
|
|
||||||
for (const page of pages) {
|
|
||||||
if (!groups[page.group]) groups[page.group] = []
|
|
||||||
groups[page.group].push(page)
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}, [pages])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPageStates(pages.map((page) => ({ page, status: "pending" })))
|
|
||||||
}, [pages])
|
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
setError(null)
|
|
||||||
setIsGenerating(true)
|
|
||||||
setProgress(0)
|
|
||||||
setCurrentPage(null)
|
|
||||||
setPageStates(pages.map((page) => ({ page, status: "loading" })))
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Simulate progress while waiting for the API
|
|
||||||
let progressValue = 0
|
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
progressValue += 2
|
|
||||||
const pageIndex = Math.floor((progressValue / 100) * pages.length)
|
|
||||||
const nextPage = pages[Math.min(pageIndex, pages.length - 1)]
|
|
||||||
if (nextPage) setCurrentPage(nextPage.title)
|
|
||||||
setProgress(Math.min(progressValue, 90))
|
|
||||||
|
|
||||||
// Update page states to show progress
|
|
||||||
setPageStates((prev) =>
|
|
||||||
prev.map((s, idx) => ({
|
|
||||||
...s,
|
|
||||||
status: idx < pageIndex ? "success" : idx === pageIndex ? "loading" : "pending",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}, 800)
|
|
||||||
|
|
||||||
// Get token from URL if provided
|
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
|
||||||
const token = urlParams.get("token") || ""
|
|
||||||
|
|
||||||
const response = await fetch(`/api/documentation/generate${token ? `?token=${token}` : ""}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token ? { "x-documentation-token": token } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
clearInterval(progressInterval)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text().catch(() => "")
|
|
||||||
let errorMessage = `生成失败(${response.status})`
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(text)
|
|
||||||
errorMessage = json.error || errorMessage
|
|
||||||
} catch {
|
|
||||||
if (text) errorMessage = text
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(100)
|
|
||||||
setCurrentPage("完成")
|
|
||||||
setPageStates(pages.map((page) => ({ page, status: "success" })))
|
|
||||||
|
|
||||||
const blob = await response.blob()
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement("a")
|
|
||||||
a.href = url
|
|
||||||
a.download = `应用功能文档_${new Date().toISOString().slice(0, 10)}.docx`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
a.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
} catch (e) {
|
|
||||||
const message = e instanceof Error ? e.message : String(e)
|
|
||||||
setError(message)
|
|
||||||
setPageStates((prev) => prev.map((s) => ({ ...s, status: "error" })))
|
|
||||||
} finally {
|
|
||||||
setIsGenerating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePreview = useCallback((path: string) => {
|
|
||||||
setPreviewPath(path)
|
|
||||||
setShowPreview(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getStatusIcon = (status: PageStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case "pending":
|
|
||||||
return <div className="w-4 h-4 rounded-full bg-gray-600" />
|
|
||||||
case "loading":
|
|
||||||
return <Loader2 className="w-4 h-4 animate-spin text-teal-400" />
|
|
||||||
case "success":
|
|
||||||
return <CheckCircle className="w-4 h-4 text-green-500" />
|
|
||||||
case "error":
|
|
||||||
return <XCircle className="w-4 h-4 text-red-500" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-background text-foreground p-4 pb-24">
|
|
||||||
<div className="max-w-md mx-auto space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3 py-2">
|
|
||||||
<FileText className="w-6 h-6 text-teal-400" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-lg font-semibold">文档生成器</h1>
|
|
||||||
<p className="text-xs text-muted-foreground">自动截图并导出专业文档</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Card */}
|
|
||||||
<div className="bg-card border border-border rounded-xl p-4 space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">页面总数</span>
|
|
||||||
<span className="font-medium text-teal-400">{pages.length} 个</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">分组数量</span>
|
|
||||||
<span className="font-medium">{Object.keys(groupedPages).length} 组</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">输出格式</span>
|
|
||||||
<span className="font-medium">Word (.docx)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress */}
|
|
||||||
{isGenerating && (
|
|
||||||
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">生成进度</span>
|
|
||||||
<span className="font-medium text-teal-400">{Math.round(progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-gradient-to-r from-teal-500 to-cyan-400 transition-all duration-300"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{currentPage && <p className="text-xs text-muted-foreground truncate">正在处理: {currentPage}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-destructive/10 border border-destructive/30 text-destructive rounded-xl p-3 text-sm">
|
|
||||||
<p className="font-medium mb-1">生成失败</p>
|
|
||||||
<p className="text-xs opacity-80">{error}</p>
|
|
||||||
<p className="text-xs mt-2 opacity-60">提示: 如需授权,请在URL中添加 ?token=your_token</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Generate Button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="w-full bg-gradient-to-r from-teal-500 to-cyan-500 text-white rounded-xl py-3.5 font-medium disabled:opacity-60 flex items-center justify-center gap-2 shadow-lg shadow-teal-500/20"
|
|
||||||
>
|
|
||||||
{isGenerating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
<span>正在生成文档...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
<span>一键生成 Word 文档</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Page List */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
||||||
<span>文档目录预览</span>
|
|
||||||
<span className="text-xs opacity-60">({pages.length}个页面)</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{Object.entries(groupedPages).map(([group, groupPages]) => (
|
|
||||||
<div key={group} className="bg-card border border-border rounded-xl overflow-hidden">
|
|
||||||
<div className="px-3 py-2 bg-muted/50 border-b border-border">
|
|
||||||
<h3 className="text-sm font-medium text-teal-400">{group}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{groupPages.map((page, index) => {
|
|
||||||
const state = pageStates.find((s) => s.page.path === page.path)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={page.path}
|
|
||||||
className="px-3 py-2.5 flex items-center gap-3 hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-muted-foreground w-5">{index + 1}</span>
|
|
||||||
{state && getStatusIcon(state.status)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">{page.title}</p>
|
|
||||||
{page.subtitle && <p className="text-xs text-muted-foreground truncate">{page.subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreview(page.path)}
|
|
||||||
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
|
||||||
title="预览页面"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
|
|
||||||
<h3 className="text-sm font-medium">文档包含内容</h3>
|
|
||||||
<ul className="text-xs text-muted-foreground space-y-2">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<span>自动生成的目录结构</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<span>所有页面的真实截图(iPhone 14 Pro Max尺寸)</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<span>每个页面的功能说明与路径</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<span>按功能模块分组整理</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Note */}
|
|
||||||
<p className="text-xs text-muted-foreground text-center">生成过程需要30-60秒,请耐心等待</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview Modal */}
|
|
||||||
{showPreview && previewPath && (
|
|
||||||
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="bg-card rounded-2xl w-full max-w-md overflow-hidden border border-border">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
||||||
<h3 className="font-medium text-sm">页面预览</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
|
|
||||||
className="p-1.5 text-muted-foreground hover:text-foreground rounded-lg"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPreview(false)}
|
|
||||||
className="p-1.5 text-muted-foreground hover:text-foreground rounded-lg"
|
|
||||||
>
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full aspect-[430/932] bg-white">
|
|
||||||
<iframe ref={iframeRef} src={previewPath} className="w-full h-full" title="Page Preview" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
143
app/globals.css
143
app/globals.css
@@ -1,143 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
|
|
||||||
/* Custom app tokens */
|
|
||||||
--app-bg: #0a1628;
|
|
||||||
--app-card: #0f2137;
|
|
||||||
--app-brand: #38bdac;
|
|
||||||
--app-brand-hover: #2da396;
|
|
||||||
--app-text: #ffffff;
|
|
||||||
--app-text-muted: #9ca3af;
|
|
||||||
--app-border: rgba(75, 85, 99, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.145 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.145 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.985 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.396 0.141 25.723);
|
|
||||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
|
||||||
--border: oklch(0.269 0 0);
|
|
||||||
--input: oklch(0.269 0 0);
|
|
||||||
--ring: oklch(0.439 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(0.269 0 0);
|
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--font-sans: "Geist", "Geist Fallback";
|
|
||||||
--font-mono: "Geist Mono", "Geist Mono Fallback";
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
|
|
||||||
/* Custom semantic tokens for app */
|
|
||||||
--color-app-bg: var(--app-bg);
|
|
||||||
--color-app-card: var(--app-card);
|
|
||||||
--color-app-brand: var(--app-brand);
|
|
||||||
--color-app-brand-hover: var(--app-brand-hover);
|
|
||||||
--color-app-text: var(--app-text);
|
|
||||||
--color-app-text-muted: var(--app-text-muted);
|
|
||||||
--color-app-border: var(--app-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import type React from "react"
|
|
||||||
import type { Metadata } from "next"
|
|
||||||
import { Geist, Geist_Mono } from "next/font/google"
|
|
||||||
import { Analytics } from "@vercel/analytics/next"
|
|
||||||
import "./globals.css"
|
|
||||||
import { LayoutWrapper } from "@/components/layout-wrapper"
|
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] })
|
|
||||||
const _geistMono = Geist_Mono({ subsets: ["latin"] })
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "一场SOUL的创业实验场 - 卡若",
|
|
||||||
description: "来自Soul派对房的真实商业故事,每天早上6-9点免费分享",
|
|
||||||
generator: "v0.app",
|
|
||||||
icons: {
|
|
||||||
icon: [
|
|
||||||
{
|
|
||||||
url: "/icon-light-32x32.png",
|
|
||||||
media: "(prefers-color-scheme: light)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "/icon-dark-32x32.png",
|
|
||||||
media: "(prefers-color-scheme: dark)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "/icon.svg",
|
|
||||||
type: "image/svg+xml",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
apple: "/apple-icon.png",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<body className={`bg-[#0a1628]`}>
|
|
||||||
<LayoutWrapper>{children}</LayoutWrapper>
|
|
||||||
<Analytics />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
151
app/my/page.tsx
151
app/my/page.tsx
@@ -1,151 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { User, ShoppingBag, Share2, LogOut, ChevronRight, BookOpen } from "lucide-react"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { AuthModal } from "@/components/modules/auth/auth-modal"
|
|
||||||
import { getFullBookPrice } from "@/lib/book-data"
|
|
||||||
|
|
||||||
export default function MyPage() {
|
|
||||||
const { user, isLoggedIn, logout } = useStore()
|
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false)
|
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-app-bg flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-app-brand" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-app-bg text-app-text pb-20 flex flex-col items-center justify-center">
|
|
||||||
<div className="p-4 w-full">
|
|
||||||
<div className="max-w-xs mx-auto text-center">
|
|
||||||
<div className="w-14 h-14 mx-auto mb-3 rounded-full bg-app-card flex items-center justify-center">
|
|
||||||
<User className="w-7 h-7 text-app-text-muted" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-base font-semibold mb-1">登录后查看更多</h2>
|
|
||||||
<p className="text-app-text-muted text-xs mb-4">查看购买记录、分销收益</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAuthModal(true)}
|
|
||||||
className="bg-app-brand hover:bg-app-brand-hover text-white px-6 py-2.5 rounded-full font-medium text-sm"
|
|
||||||
>
|
|
||||||
立即登录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AuthModal isOpen={showAuthModal} onClose={() => setShowAuthModal(false)} />
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullBookPrice = getFullBookPrice()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-app-bg text-app-text pb-20">
|
|
||||||
{/* User Profile Header */}
|
|
||||||
<div className="bg-gradient-to-b from-app-card to-app-bg p-4 pt-8">
|
|
||||||
<div className="max-w-xs mx-auto">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-11 h-11 rounded-full bg-app-brand/20 flex items-center justify-center">
|
|
||||||
<User className="w-5 h-5 text-app-brand" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold">{user?.nickname || "用户"}</h2>
|
|
||||||
<p className="text-app-text-muted text-xs">{user?.phone}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="bg-app-card/60 rounded-lg p-2.5 text-center">
|
|
||||||
<p className="text-base font-bold text-app-brand">
|
|
||||||
{user?.hasFullBook ? "全部" : user?.purchasedSections.length || 0}
|
|
||||||
</p>
|
|
||||||
<p className="text-app-text-muted text-xs">已购章节</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-app-card/60 rounded-lg p-2.5 text-center">
|
|
||||||
<p className="text-base font-bold text-app-brand">¥{(user?.earnings || 0).toFixed(1)}</p>
|
|
||||||
<p className="text-app-text-muted text-xs">累计收益</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Menu Items */}
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="max-w-xs mx-auto space-y-2">
|
|
||||||
{/* Purchase prompt */}
|
|
||||||
{!user?.hasFullBook && (
|
|
||||||
<Link href="/chapters" className="block">
|
|
||||||
<div className="bg-gradient-to-r from-app-brand/20 to-app-card rounded-lg p-3 border border-app-brand/30">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BookOpen className="w-4 h-4 text-app-brand" />
|
|
||||||
<span className="text-app-text text-sm">购买整本书</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-app-brand font-bold text-sm">¥{fullBookPrice}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Menu List - simplified, removed settings and docs */}
|
|
||||||
<div className="bg-app-card/60 rounded-lg overflow-hidden">
|
|
||||||
<Link href="/my/purchases" className="flex items-center justify-between p-3 border-b border-app-border">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ShoppingBag className="w-4 h-4 text-app-text-muted" />
|
|
||||||
<span className="text-sm">我的购买</span>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="w-4 h-4 text-app-text-muted" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/my/referral" className="flex items-center justify-between p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Share2 className="w-4 h-4 text-app-text-muted" />
|
|
||||||
<span className="text-sm">分销收益</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-app-brand text-xs">¥{(user?.earnings || 0).toFixed(1)}</span>
|
|
||||||
<ChevronRight className="w-4 h-4 text-app-text-muted" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Referral Code */}
|
|
||||||
<div className="bg-app-card/60 rounded-lg p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-app-text-muted text-xs">我的邀请码</p>
|
|
||||||
<code className="text-app-brand font-mono text-sm">{user?.referralCode}</code>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => navigator.clipboard.writeText(user?.referralCode || "")}
|
|
||||||
className="text-app-text-muted text-xs hover:text-app-text px-2 py-1 rounded bg-app-card"
|
|
||||||
>
|
|
||||||
复制
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
className="w-full flex items-center justify-center gap-2 p-2.5 text-app-text-muted hover:text-red-400 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
<span>退出登录</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { ChevronLeft, BookOpen, CheckCircle } from "lucide-react"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { bookData, getAllSections } from "@/lib/book-data"
|
|
||||||
|
|
||||||
export default function MyPurchasesPage() {
|
|
||||||
const { user, isLoggedIn } = useStore()
|
|
||||||
|
|
||||||
if (!isLoggedIn || !user) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0a1628] text-white flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-400 mb-4">请先登录</p>
|
|
||||||
<Link href="/" className="text-[#38bdac] hover:underline">
|
|
||||||
返回首页
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const allSections = getAllSections()
|
|
||||||
const purchasedCount = user.hasFullBook ? allSections.length : user.purchasedSections.length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0a1628] text-white">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="sticky top-0 z-50 bg-[#0a1628]/90 backdrop-blur-md border-b border-gray-800">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center">
|
|
||||||
<Link href="/" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors">
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
<span>返回</span>
|
|
||||||
</Link>
|
|
||||||
<h1 className="flex-1 text-center text-lg font-semibold">我的购买</h1>
|
|
||||||
<div className="w-16" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-6 border border-gray-700/50 mb-8">
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-3xl font-bold text-white">{purchasedCount}</p>
|
|
||||||
<p className="text-gray-400 text-sm">已购买章节</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-3xl font-bold text-[#38bdac]">
|
|
||||||
{user.hasFullBook ? "全书" : `${purchasedCount}/${allSections.length}`}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-400 text-sm">{user.hasFullBook ? "已解锁" : "进度"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Purchased sections */}
|
|
||||||
{user.hasFullBook ? (
|
|
||||||
<div className="bg-gradient-to-r from-[#38bdac]/20 to-[#0f2137] rounded-xl p-6 border border-[#38bdac]/30 text-center mb-8">
|
|
||||||
<CheckCircle className="w-12 h-12 text-[#38bdac] mx-auto mb-3" />
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">您已购买整本书</h3>
|
|
||||||
<p className="text-gray-400">全部55节内容已解锁,可随时阅读</p>
|
|
||||||
</div>
|
|
||||||
) : user.purchasedSections.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<BookOpen className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
|
||||||
<p className="text-gray-400 mb-4">您还没有购买任何章节</p>
|
|
||||||
<Link href="/chapters" className="text-[#38bdac] hover:underline">
|
|
||||||
去浏览章节
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-gray-400 text-sm mb-4">已购买的章节</h2>
|
|
||||||
{bookData.map((part) => {
|
|
||||||
const purchasedInPart = part.chapters.flatMap((c) =>
|
|
||||||
c.sections.filter((s) => user.purchasedSections.includes(s.id)),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (purchasedInPart.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={part.id} className="bg-[#0f2137]/40 rounded-xl border border-gray-800/50 overflow-hidden">
|
|
||||||
<div className="px-4 py-3 bg-[#0a1628]/50">
|
|
||||||
<p className="text-gray-400 text-sm">{part.title}</p>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-gray-800/30">
|
|
||||||
{purchasedInPart.map((section) => (
|
|
||||||
<Link
|
|
||||||
key={section.id}
|
|
||||||
href={`/read/${section.id}`}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 hover:bg-[#0f2137]/40 transition-colors"
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4 text-[#38bdac]" />
|
|
||||||
<span className="text-gray-400 font-mono text-sm">{section.id}</span>
|
|
||||||
<span className="text-gray-300">{section.title}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { ChevronLeft, Copy, Share2, Users, Wallet, MessageCircle, ImageIcon } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useStore, type Purchase } from "@/lib/store"
|
|
||||||
import { PosterModal } from "@/components/modules/referral/poster-modal"
|
|
||||||
import { WithdrawalModal } from "@/components/modules/referral/withdrawal-modal"
|
|
||||||
|
|
||||||
export default function ReferralPage() {
|
|
||||||
const { user, isLoggedIn, settings, getAllPurchases, getAllUsers } = useStore()
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const [showPoster, setShowPoster] = useState(false)
|
|
||||||
const [showWithdrawal, setShowWithdrawal] = useState(false)
|
|
||||||
const [referralPurchases, setReferralPurchases] = useState<Purchase[]>([])
|
|
||||||
const [referralUsers, setReferralUsers] = useState<number>(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user?.referralCode) {
|
|
||||||
const allPurchases = getAllPurchases()
|
|
||||||
const allUsers = getAllUsers()
|
|
||||||
const usersWithMyCode = allUsers.filter((u) => u.referredBy === user.referralCode)
|
|
||||||
const userIds = usersWithMyCode.map((u) => u.id)
|
|
||||||
const myReferralPurchases = allPurchases.filter((p) => userIds.includes(p.userId))
|
|
||||||
setReferralPurchases(myReferralPurchases)
|
|
||||||
setReferralUsers(usersWithMyCode.length)
|
|
||||||
}
|
|
||||||
}, [user, getAllPurchases, getAllUsers])
|
|
||||||
|
|
||||||
if (!isLoggedIn || !user) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-app-bg text-app-text flex items-center justify-center pb-20">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-app-text-muted mb-4">请先登录</p>
|
|
||||||
<Link href="/" className="text-app-brand hover:underline">
|
|
||||||
返回首页
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const referralLink = `${typeof window !== "undefined" ? window.location.origin : ""}?ref=${user.referralCode}`
|
|
||||||
const distributorShare = settings?.distributorShare || 90
|
|
||||||
const totalEarnings = user.earnings || 0
|
|
||||||
const pendingEarnings = user.pendingEarnings || 0
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
navigator.clipboard.writeText(referralLink)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShare = async () => {
|
|
||||||
const shareText = `我正在读《一场SOUL的创业实验场》,每天6-9点的真实商业故事,推荐给你!${referralLink}`
|
|
||||||
try {
|
|
||||||
if (typeof navigator.share === 'function' && typeof navigator.canShare === 'function') {
|
|
||||||
await navigator.share({
|
|
||||||
title: "一场SOUL的创业实验场",
|
|
||||||
text: "来自Soul派对房的真实商业故事",
|
|
||||||
url: referralLink,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await navigator.clipboard.writeText(shareText)
|
|
||||||
alert("分享文案已复制,快去朋友圈或Soul派对分享吧!")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await navigator.clipboard.writeText(shareText)
|
|
||||||
alert("分享文案已复制!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShareToWechat = async () => {
|
|
||||||
const shareText = `📖 推荐一本好书《一场SOUL的创业实验场》
|
|
||||||
|
|
||||||
这是卡若每天早上6-9点在Soul派对房分享的真实商业故事,55个真实案例,讲透创业的底层逻辑。
|
|
||||||
|
|
||||||
👉 点击阅读: ${referralLink}
|
|
||||||
|
|
||||||
#创业 #商业思维 #Soul派对`
|
|
||||||
await navigator.clipboard.writeText(shareText)
|
|
||||||
alert("朋友圈文案已复制!\n\n打开微信 → 发朋友圈 → 粘贴即可")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShareToSoul = async () => {
|
|
||||||
const shareText = `在Soul派对房听卡若讲了好多真实的创业故事,他把这些故事整理成了一本书《一场SOUL的创业实验场》,推荐给你们~
|
|
||||||
|
|
||||||
每天早上6-9点直播,这本书就是直播内容的精华版。
|
|
||||||
|
|
||||||
链接: ${referralLink}`
|
|
||||||
await navigator.clipboard.writeText(shareText)
|
|
||||||
alert("Soul分享文案已复制!\n\n打开Soul → 发动态 → 粘贴即可")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-app-bg text-app-text pb-24">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="sticky top-0 z-50 bg-app-bg/90 backdrop-blur-md border-b border-app-border">
|
|
||||||
<div className="max-w-xs mx-auto px-4 py-3 flex items-center">
|
|
||||||
<Link href="/my" className="flex items-center gap-1 text-app-text-muted hover:text-app-text">
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
<h1 className="flex-1 text-center text-sm font-semibold">分销中心</h1>
|
|
||||||
<div className="w-5" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="max-w-xs mx-auto px-4 py-4">
|
|
||||||
{/* Earnings Card */}
|
|
||||||
<div className="bg-gradient-to-br from-app-brand/20 to-app-card rounded-xl p-4 border border-app-brand/30 mb-3">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Wallet className="w-4 h-4 text-app-brand" />
|
|
||||||
<span className="text-app-text-muted text-xs">累计收益</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-app-brand text-xs">{distributorShare}%返利</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-2xl font-bold text-app-text mb-0.5">¥{totalEarnings.toFixed(2)}</p>
|
|
||||||
<p className="text-app-text-muted text-xs mb-3">待结算: ¥{pendingEarnings.toFixed(2)}</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
disabled={totalEarnings < 10}
|
|
||||||
onClick={() => setShowWithdrawal(true)}
|
|
||||||
className="w-full bg-app-brand hover:bg-app-brand-hover text-white h-8 text-xs"
|
|
||||||
>
|
|
||||||
{totalEarnings < 10 ? `满10元可提现` : "申请提现"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
|
||||||
<div className="bg-app-card/60 rounded-lg p-2.5 text-center">
|
|
||||||
<Users className="w-4 h-4 text-app-brand mx-auto mb-1" />
|
|
||||||
<p className="text-base font-bold text-app-text">{referralUsers}</p>
|
|
||||||
<p className="text-app-text-muted text-xs">邀请人数</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-app-card/60 rounded-lg p-2.5 text-center">
|
|
||||||
<Share2 className="w-4 h-4 text-app-brand mx-auto mb-1" />
|
|
||||||
<p className="text-base font-bold text-app-text">{referralPurchases.length}</p>
|
|
||||||
<p className="text-app-text-muted text-xs">成交订单</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Referral link */}
|
|
||||||
<div className="bg-app-card/60 rounded-xl p-3 border border-app-border mb-3">
|
|
||||||
<p className="text-app-text text-xs font-medium mb-2">我的专属链接</p>
|
|
||||||
<div className="flex gap-2 mb-2">
|
|
||||||
<div className="flex-1 bg-app-bg rounded-lg px-2.5 py-1.5 text-app-text-muted text-xs truncate font-mono">
|
|
||||||
{referralLink}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleCopy}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-app-border text-app-text hover:bg-app-card bg-transparent text-xs h-7 px-2"
|
|
||||||
>
|
|
||||||
<Copy className="w-3 h-3 mr-1" />
|
|
||||||
{copied ? "已复制" : "复制"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-app-text-muted text-xs">
|
|
||||||
邀请码: <span className="text-app-brand font-mono">{user.referralCode}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share buttons - improved for WeChat/Soul */}
|
|
||||||
<div className="space-y-2 mb-3">
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowPoster(true)}
|
|
||||||
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white py-4 text-xs"
|
|
||||||
>
|
|
||||||
<ImageIcon className="w-4 h-4 mr-2" />
|
|
||||||
生成推广海报
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleShareToWechat}
|
|
||||||
className="w-full bg-green-600 hover:bg-green-700 text-white py-4 text-xs"
|
|
||||||
>
|
|
||||||
<MessageCircle className="w-4 h-4 mr-2" />
|
|
||||||
分享到朋友圈
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleShare}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full border-app-border text-app-text hover:bg-app-card bg-transparent py-4 text-xs"
|
|
||||||
>
|
|
||||||
更多分享方式
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PosterModal
|
|
||||||
isOpen={showPoster}
|
|
||||||
onClose={() => setShowPoster(false)}
|
|
||||||
referralLink={referralLink}
|
|
||||||
referralCode={user.referralCode}
|
|
||||||
nickname={user.nickname}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WithdrawalModal
|
|
||||||
isOpen={showWithdrawal}
|
|
||||||
onClose={() => setShowWithdrawal(false)}
|
|
||||||
availableAmount={totalEarnings}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Recent earnings */}
|
|
||||||
{referralPurchases.length > 0 && (
|
|
||||||
<div className="bg-app-card/60 rounded-xl border border-app-border">
|
|
||||||
<div className="p-2.5 border-b border-app-border">
|
|
||||||
<p className="text-app-text text-xs font-medium">收益明细</p>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-app-border max-h-40 overflow-auto">
|
|
||||||
{referralPurchases.slice(0, 5).map((purchase) => (
|
|
||||||
<div key={purchase.id} className="p-2.5 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-app-text text-xs">{purchase.type === "fullbook" ? "整本书" : "单节"}</p>
|
|
||||||
<p className="text-app-text-muted text-xs">
|
|
||||||
{new Date(purchase.createdAt).toLocaleDateString("zh-CN")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-app-brand text-sm font-semibold">
|
|
||||||
+¥{(purchase.referrerEarnings || 0).toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { ArrowLeft, Phone, Bell, Shield, HelpCircle } from "lucide-react"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const { user, updateUser } = useStore()
|
|
||||||
const [nickname, setNickname] = useState(user?.nickname || "")
|
|
||||||
const [saved, setSaved] = useState(false)
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (user) {
|
|
||||||
updateUser(user.id, { nickname })
|
|
||||||
setSaved(true)
|
|
||||||
setTimeout(() => setSaved(false), 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[#0a1628] text-white pb-20">
|
|
||||||
<div className="sticky top-0 z-10 bg-[#0a1628]/95 backdrop-blur-md border-b border-gray-700/50">
|
|
||||||
<div className="max-w-md mx-auto flex items-center gap-4 p-4">
|
|
||||||
<Link href="/my" className="p-2 -ml-2">
|
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-lg font-semibold">账户设置</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="max-w-md mx-auto space-y-4">
|
|
||||||
{/* Profile Settings */}
|
|
||||||
<div className="bg-[#0f2137]/60 rounded-xl p-4">
|
|
||||||
<h3 className="text-gray-400 text-sm mb-4">个人信息</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-gray-400 text-xs">昵称</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={nickname}
|
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
|
||||||
className="w-full bg-[#0a1628] border border-gray-700 rounded-lg px-4 py-3 text-white mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-gray-400 text-xs">手机号</label>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<Phone className="w-4 h-4 text-gray-500" />
|
|
||||||
<span className="text-gray-400">{user?.phone}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="w-full mt-4 bg-[#38bdac] hover:bg-[#2da396] text-white py-3 rounded-lg font-medium"
|
|
||||||
>
|
|
||||||
{saved ? "已保存" : "保存修改"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Other Settings */}
|
|
||||||
<div className="bg-[#0f2137]/60 rounded-xl overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-700/30">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Bell className="w-5 h-5 text-gray-400" />
|
|
||||||
<span>消息通知</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-10 h-5 bg-[#38bdac] rounded-full relative">
|
|
||||||
<div className="w-4 h-4 bg-white rounded-full absolute right-0.5 top-0.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-700/30">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Shield className="w-5 h-5 text-gray-400" />
|
|
||||||
<span>隐私设置</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/docs" className="flex items-center justify-between p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<HelpCircle className="w-5 h-5 text-gray-400" />
|
|
||||||
<span>帮助文档</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
26
app/page.tsx
26
app/page.tsx
@@ -1,26 +0,0 @@
|
|||||||
import { BookCover } from "@/components/book-cover"
|
|
||||||
import { BookIntro } from "@/components/book-intro"
|
|
||||||
import { TableOfContents } from "@/components/table-of-contents"
|
|
||||||
import { PurchaseSection } from "@/components/purchase-section"
|
|
||||||
import { Footer } from "@/components/footer"
|
|
||||||
import { getBookStructure } from "@/lib/book-file-system"
|
|
||||||
|
|
||||||
// Force dynamic rendering if we want it to update on every request without rebuild
|
|
||||||
// or use revalidation. For now, we can leave it default (static if no dynamic functions used, but fs usage makes it dynamic in dev usually).
|
|
||||||
// Actually, in App Router, using fs directly in a Server Component usually makes it static at build time unless using dynamic functions.
|
|
||||||
// To ensure it updates when files change in dev, it should be fine.
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export default async function HomePage() {
|
|
||||||
const parts = getBookStructure()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[#0a1628] text-white pb-20">
|
|
||||||
<BookCover />
|
|
||||||
<BookIntro />
|
|
||||||
<TableOfContents parts={parts} />
|
|
||||||
<PurchaseSection />
|
|
||||||
<Footer />
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { notFound } from "next/navigation"
|
|
||||||
import { ChapterContent } from "@/components/chapter-content"
|
|
||||||
import { getSectionBySlug, getChapterBySectionSlug } from "@/lib/book-file-system"
|
|
||||||
import { specialSections } from "@/lib/book-data"
|
|
||||||
|
|
||||||
interface ReadPageProps {
|
|
||||||
params: Promise<{ id: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
|
||||||
export const runtime = "nodejs"
|
|
||||||
|
|
||||||
export default async function ReadPage({ params }: ReadPageProps) {
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
if (id === "preface") {
|
|
||||||
return <ChapterContent section={specialSections.preface} partTitle="序言" chapterTitle="" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id === "epilogue") {
|
|
||||||
return <ChapterContent section={specialSections.epilogue} partTitle="尾声" chapterTitle="" />
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const section = getSectionBySlug(id)
|
|
||||||
if (!section) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = getChapterBySectionSlug(id)
|
|
||||||
if (!context) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
return <ChapterContent section={section} partTitle={context.part.title} chapterTitle={context.chapter.title} />
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[v0] Error in ReadPage:", error)
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
book
1
book
Submodule book deleted from 7994e19cda
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"rsc": true,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "",
|
|
||||||
"css": "app/globals.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide"
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { X, Phone, User, Gift } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
interface AuthModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
defaultTab?: "login" | "register"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalProps) {
|
|
||||||
const [tab, setTab] = useState<"login" | "register">(defaultTab)
|
|
||||||
const [phone, setPhone] = useState("")
|
|
||||||
const [code, setCode] = useState("")
|
|
||||||
const [nickname, setNickname] = useState("")
|
|
||||||
const [referralCode, setReferralCode] = useState("")
|
|
||||||
const [error, setError] = useState("")
|
|
||||||
const [codeSent, setCodeSent] = useState(false)
|
|
||||||
|
|
||||||
const { login, register } = useStore()
|
|
||||||
|
|
||||||
const handleSendCode = () => {
|
|
||||||
if (phone.length !== 11) {
|
|
||||||
setError("请输入正确的手机号")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Simulate sending verification code
|
|
||||||
setCodeSent(true)
|
|
||||||
setError("")
|
|
||||||
alert("验证码已发送,测试验证码: 123456")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
setError("")
|
|
||||||
const success = await login(phone, code)
|
|
||||||
if (success) {
|
|
||||||
onClose()
|
|
||||||
} else {
|
|
||||||
setError("验证码错误或用户不存在,请先注册")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRegister = async () => {
|
|
||||||
setError("")
|
|
||||||
if (!nickname.trim()) {
|
|
||||||
setError("请输入昵称")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const success = await register(phone, nickname, referralCode || undefined)
|
|
||||||
if (success) {
|
|
||||||
onClose()
|
|
||||||
} else {
|
|
||||||
setError("该手机号已注册")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex border-b border-gray-700/50">
|
|
||||||
<button
|
|
||||||
onClick={() => setTab("login")}
|
|
||||||
className={`flex-1 py-4 text-center transition-colors ${
|
|
||||||
tab === "login" ? "text-white border-b-2 border-[#38bdac]" : "text-gray-400 hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
登录
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTab("register")}
|
|
||||||
className={`flex-1 py-4 text-center transition-colors ${
|
|
||||||
tab === "register" ? "text-white border-b-2 border-[#38bdac]" : "text-gray-400 hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
注册
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6">
|
|
||||||
{tab === "login" ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">手机号</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="tel"
|
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
|
||||||
placeholder="请输入手机号"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
maxLength={11}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">验证码</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
placeholder="请输入验证码"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
maxLength={6}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleSendCode}
|
|
||||||
disabled={codeSent}
|
|
||||||
className="whitespace-nowrap border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
|
||||||
>
|
|
||||||
{codeSent ? "已发送" : "获取验证码"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
|
||||||
|
|
||||||
<Button onClick={handleLogin} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5">
|
|
||||||
登录
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">手机号</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="tel"
|
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
|
||||||
placeholder="请输入手机号"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
maxLength={11}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">昵称</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={nickname}
|
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
|
||||||
placeholder="请输入昵称"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">验证码</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
placeholder="请输入验证码"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
maxLength={6}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleSendCode}
|
|
||||||
disabled={codeSent}
|
|
||||||
className="whitespace-nowrap border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
|
||||||
>
|
|
||||||
{codeSent ? "已发送" : "获取验证码"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">邀请码 (选填)</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={referralCode}
|
|
||||||
onChange={(e) => setReferralCode(e.target.value)}
|
|
||||||
placeholder="填写邀请码可获得优惠"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
|
||||||
|
|
||||||
<Button onClick={handleRegister} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5">
|
|
||||||
注册
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { BookOpen } from "lucide-react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { getFullBookPrice, getAllSections } from "@/lib/book-data"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { AuthModal } from "./modules/auth/auth-modal"
|
|
||||||
import { PaymentModal } from "./modules/payment/payment-modal"
|
|
||||||
|
|
||||||
export function BookCover() {
|
|
||||||
const [fullBookPrice, setFullBookPrice] = useState(9.9)
|
|
||||||
const [sectionsCount, setSectionsCount] = useState(55)
|
|
||||||
const [isAuthOpen, setIsAuthOpen] = useState(false)
|
|
||||||
const [isPaymentOpen, setIsPaymentOpen] = useState(false)
|
|
||||||
const { isLoggedIn } = useStore()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sections = getAllSections()
|
|
||||||
setSectionsCount(sections.length)
|
|
||||||
setFullBookPrice(getFullBookPrice(sections.length))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative min-h-[85vh] flex flex-col items-center justify-center px-4 py-8 overflow-hidden bg-app-bg">
|
|
||||||
{/* Background decorative lines - simplified */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden opacity-50">
|
|
||||||
<svg className="absolute w-full h-full" viewBox="0 0 800 600" fill="none">
|
|
||||||
<path
|
|
||||||
d="M-100 300 Q 200 100, 400 200 T 900 150"
|
|
||||||
stroke="rgba(56, 189, 172, 0.2)"
|
|
||||||
strokeWidth="1"
|
|
||||||
strokeDasharray="8 8"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - more compact for mobile */}
|
|
||||||
<div className="relative z-10 w-full max-w-sm mx-auto text-center">
|
|
||||||
{/* Soul badge */}
|
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-app-card/80 backdrop-blur-md border border-app-brand/30 mb-6">
|
|
||||||
<span className="text-app-brand text-sm font-medium">Soul · 派对房</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main title - smaller on mobile */}
|
|
||||||
<h1 className="text-3xl font-bold mb-3 leading-tight text-app-text">
|
|
||||||
一场SOUL的
|
|
||||||
<br />
|
|
||||||
创业实验场
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Subtitle */}
|
|
||||||
<p className="text-app-text-muted text-sm mb-4">来自Soul派对房的真实商业故事</p>
|
|
||||||
|
|
||||||
{/* Quote - smaller */}
|
|
||||||
<p className="text-app-text-muted/80 italic text-sm mb-6">"社会不是靠努力,是靠洞察与选择"</p>
|
|
||||||
|
|
||||||
{/* Price info - compact card */}
|
|
||||||
<div className="bg-app-card/60 backdrop-blur-md rounded-xl p-4 mb-6 border border-app-border">
|
|
||||||
<div className="flex items-center justify-center gap-6 text-sm">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-xl font-bold text-app-brand">¥{fullBookPrice.toFixed(1)}</p>
|
|
||||||
<p className="text-app-text-muted text-xs">整本价格</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-8 bg-app-border" />
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-xl font-bold text-app-text">{sectionsCount}</p>
|
|
||||||
<p className="text-app-text-muted text-xs">商业案例</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Author info - compact */}
|
|
||||||
<div className="flex justify-between items-center px-2 mb-6 text-sm">
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="text-app-text-muted text-xs">作者</p>
|
|
||||||
<p className="text-app-brand font-medium">卡若</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-app-text-muted text-xs">每日直播</p>
|
|
||||||
<p className="text-app-text font-medium">06:00-09:00</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA Button */}
|
|
||||||
<Link href="/chapters" className="block">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="w-full bg-app-brand hover:bg-app-brand-hover text-white py-5 text-base rounded-xl flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<BookOpen className="w-5 h-5" />
|
|
||||||
立即阅读
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<p className="text-app-text-muted text-xs mt-4">首章免费 · 部分章节3天后解锁</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<AuthModal isOpen={isAuthOpen} onClose={() => setIsAuthOpen(false)} />
|
|
||||||
<PaymentModal
|
|
||||||
isOpen={isPaymentOpen}
|
|
||||||
onClose={() => setIsPaymentOpen(false)}
|
|
||||||
type="fullbook"
|
|
||||||
amount={fullBookPrice}
|
|
||||||
onSuccess={() => window.location.reload()}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
export function BookIntro() {
|
|
||||||
return (
|
|
||||||
<section className="py-16 px-4">
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
{/* Glass card */}
|
|
||||||
<div className="bg-[#0f2137]/80 backdrop-blur-xl rounded-2xl p-8 border border-[#38bdac]/20">
|
|
||||||
{/* Quote */}
|
|
||||||
<blockquote className="text-lg md:text-xl text-gray-200 leading-relaxed mb-6">
|
|
||||||
"这不是一本教你成功的鸡汤书。这是我每天早上6点到9点,在Soul派对房和几百个陌生人分享的真实故事。"
|
|
||||||
</blockquote>
|
|
||||||
|
|
||||||
{/* Author */}
|
|
||||||
<p className="text-[#38bdac] text-lg">— 卡若</p>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-700/50 grid grid-cols-3 gap-4 text-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl md:text-3xl font-bold text-white">55+</p>
|
|
||||||
<p className="text-gray-400 text-sm">真实案例</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl md:text-3xl font-bold text-white">11</p>
|
|
||||||
<p className="text-gray-400 text-sm">核心章节</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl md:text-3xl font-bold text-white">100+</p>
|
|
||||||
<p className="text-gray-400 text-sm">商业洞察</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { usePathname } from "next/navigation"
|
|
||||||
import { Home, MessageCircle, User } from "lucide-react"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { QRCodeModal } from "./modules/marketing/qr-code-modal"
|
|
||||||
|
|
||||||
export function BottomNav() {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const [showQRModal, setShowQRModal] = useState(false)
|
|
||||||
|
|
||||||
if (pathname.startsWith("/documentation")) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ href: "/", icon: Home, label: "首页" },
|
|
||||||
{ action: () => setShowQRModal(true), icon: MessageCircle, label: "派对群" },
|
|
||||||
{ href: "/my", icon: User, label: "我的" },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<nav className="fixed bottom-0 left-0 right-0 z-40 bg-[#0f2137]/95 backdrop-blur-md border-t border-gray-700/50">
|
|
||||||
<div className="flex items-center justify-around py-3 max-w-lg mx-auto">
|
|
||||||
{navItems.map((item, index) => {
|
|
||||||
const isActive = item.href ? pathname === item.href : false
|
|
||||||
const Icon = item.icon
|
|
||||||
|
|
||||||
if (item.action) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={item.action}
|
|
||||||
className="flex flex-col items-center py-2 px-6 text-gray-400 hover:text-[#38bdac] transition-colors"
|
|
||||||
>
|
|
||||||
<Icon className="w-6 h-6 mb-1" />
|
|
||||||
<span className="text-xs">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={index}
|
|
||||||
href={item.href!}
|
|
||||||
className={`flex flex-col items-center py-2 px-6 transition-colors ${
|
|
||||||
isActive ? "text-[#38bdac]" : "text-gray-400 hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-6 h-6 mb-1" />
|
|
||||||
<span className="text-xs">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<QRCodeModal isOpen={showQRModal} onClose={() => setShowQRModal(false)} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { PaymentModal } from "@/components/modules/payment/payment-modal"
|
|
||||||
import { AuthModal } from "@/components/modules/auth/auth-modal"
|
|
||||||
|
|
||||||
interface BuyFullBookButtonProps {
|
|
||||||
price: number
|
|
||||||
className?: string
|
|
||||||
size?: "default" | "sm" | "lg" | "icon"
|
|
||||||
children?: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BuyFullBookButton({ price, className, size = "default", children }: BuyFullBookButtonProps) {
|
|
||||||
const [isPaymentOpen, setIsPaymentOpen] = useState(false)
|
|
||||||
const [isAuthOpen, setIsAuthOpen] = useState(false)
|
|
||||||
const { isLoggedIn } = useStore()
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
setIsAuthOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsPaymentOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size={size}
|
|
||||||
className={className}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
{children || `购买全书 ¥${price}`}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AuthModal
|
|
||||||
isOpen={isAuthOpen}
|
|
||||||
onClose={() => setIsAuthOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PaymentModal
|
|
||||||
isOpen={isPaymentOpen}
|
|
||||||
onClose={() => setIsPaymentOpen(false)}
|
|
||||||
type="fullbook"
|
|
||||||
amount={price}
|
|
||||||
onSuccess={() => {
|
|
||||||
// Refresh or redirect
|
|
||||||
window.location.reload()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { ChevronLeft, Lock, Share2, BookOpen, Clock, MessageCircle } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { type Section, getFullBookPrice, isSectionUnlocked } from "@/lib/book-data"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { AuthModal } from "./modules/auth/auth-modal"
|
|
||||||
import { PaymentModal } from "./modules/payment/payment-modal"
|
|
||||||
import { UserMenu } from "./user-menu"
|
|
||||||
import { QRCodeModal } from "./modules/marketing/qr-code-modal"
|
|
||||||
import { ReferralShare } from "./modules/referral/referral-share"
|
|
||||||
|
|
||||||
interface ChapterContentProps {
|
|
||||||
section: Section & { filePath: string }
|
|
||||||
partTitle: string
|
|
||||||
chapterTitle: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChapterContent({ section, partTitle, chapterTitle }: ChapterContentProps) {
|
|
||||||
const [content, setContent] = useState<string>("")
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [isAuthOpen, setIsAuthOpen] = useState(false)
|
|
||||||
const [isPaymentOpen, setIsPaymentOpen] = useState(false)
|
|
||||||
const [isQRModalOpen, setIsQRModalOpen] = useState(false)
|
|
||||||
const [paymentType, setPaymentType] = useState<"section" | "fullbook">("section")
|
|
||||||
const [fullBookPrice, setFullBookPrice] = useState(9.9)
|
|
||||||
|
|
||||||
const { user, isLoggedIn, hasPurchased, settings } = useStore()
|
|
||||||
const distributorShare = settings?.distributorShare || 90
|
|
||||||
|
|
||||||
const isUnlocked = isSectionUnlocked(section)
|
|
||||||
const canAccess = section.isFree || isUnlocked || (isLoggedIn && hasPurchased(section.id))
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFullBookPrice(getFullBookPrice())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadContent() {
|
|
||||||
try {
|
|
||||||
if (section.content) {
|
|
||||||
setContent(section.content)
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== "undefined" && section.filePath.startsWith("custom/")) {
|
|
||||||
const customSections = JSON.parse(localStorage.getItem("custom_sections") || "[]") as Section[]
|
|
||||||
const customSection = customSections.find((s) => s.id === section.id)
|
|
||||||
if (customSection?.content) {
|
|
||||||
setContent(customSection.content)
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/content?path=${encodeURIComponent(section.filePath)}`)
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
if (!data.isCustom) {
|
|
||||||
setContent(data.content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load content:", error)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadContent()
|
|
||||||
}, [section.filePath, section.id, section.content])
|
|
||||||
|
|
||||||
const handlePurchaseClick = (type: "section" | "fullbook") => {
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
setIsAuthOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setPaymentType(type)
|
|
||||||
setIsPaymentOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShare = async () => {
|
|
||||||
const url = user?.referralCode ? `${window.location.href}?ref=${user.referralCode}` : window.location.href
|
|
||||||
const shareData = {
|
|
||||||
title: section.title,
|
|
||||||
text: `来自Soul派对房的真实商业故事: ${section.title}`,
|
|
||||||
url: url,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (navigator.share && navigator.canShare && navigator.canShare(shareData)) {
|
|
||||||
await navigator.share(shareData)
|
|
||||||
} else {
|
|
||||||
navigator.clipboard.writeText(url)
|
|
||||||
alert(
|
|
||||||
`链接已复制!分享后他人购买,你可获得${distributorShare}%返利 (¥${((fullBookPrice * distributorShare) / 100).toFixed(1)})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as Error).name !== "AbortError") {
|
|
||||||
navigator.clipboard.writeText(url)
|
|
||||||
alert(`链接已复制!分享后他人购买,你可获得${distributorShare}%返利`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewContent = content.slice(0, 500)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#0a1628] text-white pb-20">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="sticky top-0 z-50 bg-[#0a1628]/90 backdrop-blur-md border-b border-gray-800">
|
|
||||||
<div className="max-w-3xl mx-auto px-4 py-4 flex items-center justify-between">
|
|
||||||
<Link href="/chapters" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors">
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
<span className="hidden sm:inline">目录</span>
|
|
||||||
</Link>
|
|
||||||
<div className="text-center flex-1 px-4">
|
|
||||||
<p className="text-gray-500 text-xs">{partTitle}</p>
|
|
||||||
{chapterTitle && <p className="text-gray-400 text-sm truncate">{chapterTitle}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ReferralShare
|
|
||||||
sectionTitle={section.title}
|
|
||||||
fullBookPrice={fullBookPrice}
|
|
||||||
distributorShare={distributorShare}
|
|
||||||
/>
|
|
||||||
<UserMenu />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<main className="max-w-3xl mx-auto px-4 py-8">
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-2 text-[#38bdac] text-sm mb-2">
|
|
||||||
<BookOpen className="w-4 h-4" />
|
|
||||||
<span>{section.id}</span>
|
|
||||||
{section.unlockAfterDays && !section.isFree && (
|
|
||||||
<span className="ml-2 px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded-full flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{isUnlocked ? "已免费解锁" : `${section.unlockAfterDays}天后免费`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-white leading-tight">{section.title}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<div className="w-8 h-8 border-2 border-[#38bdac] border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : canAccess ? (
|
|
||||||
<>
|
|
||||||
<article className="prose prose-invert prose-lg max-w-none">
|
|
||||||
<div className="text-gray-300 leading-relaxed whitespace-pre-line">
|
|
||||||
{content.split("\n").map((paragraph, index) => (
|
|
||||||
<p key={index} className="mb-4">
|
|
||||||
{paragraph}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{/* Join Party Group CTA */}
|
|
||||||
<div className="mt-12 bg-gradient-to-r from-[#38bdac]/10 to-[#1a3a4a]/50 rounded-2xl p-6 border border-[#38bdac]/30">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-[#38bdac]/20 flex items-center justify-center flex-shrink-0">
|
|
||||||
<MessageCircle className="w-6 h-6 text-[#38bdac]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-white font-semibold mb-1">想听更多商业故事?</h3>
|
|
||||||
<p className="text-gray-400 text-sm">每天早上6-9点,卡若在Soul派对房分享真实案例</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsQRModalOpen(true)}
|
|
||||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white whitespace-nowrap"
|
|
||||||
>
|
|
||||||
加入派对群
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<article className="prose prose-invert prose-lg max-w-none relative">
|
|
||||||
<div className="text-gray-300 leading-relaxed whitespace-pre-line">
|
|
||||||
{previewContent.split("\n").map((paragraph, index) => (
|
|
||||||
<p key={index} className="mb-4">
|
|
||||||
{paragraph}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-40 bg-gradient-to-t from-[#0a1628] to-transparent" />
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{/* Purchase prompt */}
|
|
||||||
<div className="mt-8 bg-[#0f2137]/80 backdrop-blur-xl rounded-2xl p-8 border border-gray-700/50 text-center">
|
|
||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-800/50 flex items-center justify-center">
|
|
||||||
<Lock className="w-8 h-8 text-gray-500" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">解锁完整内容</h3>
|
|
||||||
<p className="text-gray-400 mb-6">
|
|
||||||
{isLoggedIn ? "购买本节或整本书以阅读完整内容" : "登录后购买即可阅读完整内容"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{section.unlockAfterDays && (
|
|
||||||
<p className="text-orange-400 text-sm mb-4 flex items-center justify-center gap-1">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
本节将在{section.unlockAfterDays}天后免费解锁
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<Button
|
|
||||||
onClick={() => handlePurchaseClick("section")}
|
|
||||||
variant="outline"
|
|
||||||
className="border-gray-600 text-white hover:bg-gray-700/50 px-6 py-5"
|
|
||||||
>
|
|
||||||
购买本节 ¥{section.price}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handlePurchaseClick("fullbook")}
|
|
||||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white px-6 py-5"
|
|
||||||
>
|
|
||||||
购买全书 ¥{fullBookPrice.toFixed(1)}
|
|
||||||
<span className="ml-2 text-xs opacity-80">省82%</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm mt-4">
|
|
||||||
分享本书,他人购买你可获得 <span className="text-[#38bdac]">{distributorShare}%返利</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Join Party Group */}
|
|
||||||
<div className="mt-8 bg-gradient-to-r from-[#38bdac]/10 to-[#1a3a4a]/50 rounded-2xl p-6 border border-[#38bdac]/30">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-[#38bdac]/20 flex items-center justify-center flex-shrink-0">
|
|
||||||
<MessageCircle className="w-6 h-6 text-[#38bdac]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-white font-semibold mb-1">不想花钱?来派对群免费听!</h3>
|
|
||||||
<p className="text-gray-400 text-sm">每天早上6-9点,卡若在Soul派对房免费分享</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsQRModalOpen(true)}
|
|
||||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white whitespace-nowrap"
|
|
||||||
>
|
|
||||||
加入派对群
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-800 flex justify-between">
|
|
||||||
<Link href="/chapters" className="text-gray-400 hover:text-white transition-colors">
|
|
||||||
← 返回目录
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={handleShare}
|
|
||||||
className="text-[#38bdac] hover:text-[#4fd4c4] transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Share2 className="w-4 h-4" />
|
|
||||||
分享赚 ¥{((section.price * distributorShare) / 100).toFixed(1)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<AuthModal isOpen={isAuthOpen} onClose={() => setIsAuthOpen(false)} />
|
|
||||||
<PaymentModal
|
|
||||||
isOpen={isPaymentOpen}
|
|
||||||
onClose={() => setIsPaymentOpen(false)}
|
|
||||||
type={paymentType}
|
|
||||||
sectionId={section.id}
|
|
||||||
sectionTitle={section.title}
|
|
||||||
amount={paymentType === "section" ? section.price : fullBookPrice}
|
|
||||||
onSuccess={() => window.location.reload()}
|
|
||||||
/>
|
|
||||||
<QRCodeModal isOpen={isQRModalOpen} onClose={() => setIsQRModalOpen(false)} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { ChevronRight, Lock, Unlock, BookOpen } from "lucide-react"
|
|
||||||
import { Part } from "@/lib/book-data"
|
|
||||||
|
|
||||||
interface ChaptersListProps {
|
|
||||||
parts: Part[]
|
|
||||||
specialSections?: {
|
|
||||||
preface?: { title: string }
|
|
||||||
epilogue?: { title: string }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChaptersList({ parts, specialSections }: ChaptersListProps) {
|
|
||||||
const [expandedPart, setExpandedPart] = useState<string | null>(parts.length > 0 ? parts[0].id : null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Special sections - Preface */}
|
|
||||||
{specialSections?.preface && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Link href={`/read/preface`} className="block group">
|
|
||||||
<div className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-4 border border-transparent hover:border-[#38bdac]/30 transition-all flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Unlock className="w-4 h-4 text-[#38bdac]" />
|
|
||||||
<span className="text-gray-300 group-hover:text-white transition-colors">
|
|
||||||
{specialSections.preface.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[#38bdac] text-sm">免费</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Parts */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{parts.map((part) => (
|
|
||||||
<div
|
|
||||||
key={part.id}
|
|
||||||
className="bg-[#0f2137]/40 backdrop-blur-md rounded-xl border border-gray-800/50 overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Part header */}
|
|
||||||
<button
|
|
||||||
onClick={() => setExpandedPart(expandedPart === part.id ? null : part.id)}
|
|
||||||
className="w-full p-5 flex items-center justify-between hover:bg-[#0f2137]/60 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-[#38bdac] font-mono text-lg">{part.number}</span>
|
|
||||||
<div className="text-left">
|
|
||||||
<h2 className="text-white text-xl font-semibold">{part.title}</h2>
|
|
||||||
<p className="text-gray-500 text-sm">{part.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight
|
|
||||||
className={`w-5 h-5 text-gray-500 transition-transform ${
|
|
||||||
expandedPart === part.id ? "rotate-90" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Chapters and sections */}
|
|
||||||
{expandedPart === part.id && (
|
|
||||||
<div className="border-t border-gray-800/50">
|
|
||||||
{part.chapters.map((chapter) => (
|
|
||||||
<div key={chapter.id} className="border-b border-gray-800/30 last:border-b-0">
|
|
||||||
{/* Chapter title */}
|
|
||||||
<div className="px-5 py-3 bg-[#0a1628]/50">
|
|
||||||
<h3 className="text-gray-400 text-sm font-medium flex items-center gap-2">
|
|
||||||
<BookOpen className="w-4 h-4" />
|
|
||||||
{chapter.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sections */}
|
|
||||||
<div className="divide-y divide-gray-800/30">
|
|
||||||
{chapter.sections.map((section) => (
|
|
||||||
<Link
|
|
||||||
key={section.id}
|
|
||||||
href={`/read/${section.id}`}
|
|
||||||
className="block px-5 py-4 hover:bg-[#0f2137]/40 transition-colors group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{section.isFree ? (
|
|
||||||
<Unlock className="w-4 h-4 text-[#38bdac]" />
|
|
||||||
) : (
|
|
||||||
<Lock className="w-4 h-4 text-gray-500" />
|
|
||||||
)}
|
|
||||||
<span className="text-gray-400 font-mono text-sm">{section.id}</span>
|
|
||||||
<span className="text-gray-300 group-hover:text-white transition-colors">
|
|
||||||
{section.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{section.isFree ? (
|
|
||||||
<span className="text-[#38bdac] text-sm">免费</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-500 text-sm">¥{section.price}</span>
|
|
||||||
)}
|
|
||||||
<ChevronRight className="w-4 h-4 text-gray-600 group-hover:text-gray-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Special sections - Epilogue */}
|
|
||||||
{specialSections?.epilogue && (
|
|
||||||
<div className="mt-8 space-y-3">
|
|
||||||
<Link href={`/read/epilogue`} className="block group">
|
|
||||||
<div className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-4 border border-transparent hover:border-[#38bdac]/30 transition-all flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Unlock className="w-4 h-4 text-[#38bdac]" />
|
|
||||||
<span className="text-gray-300 group-hover:text-white transition-colors">
|
|
||||||
{specialSections.epilogue.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[#38bdac] text-sm">免费</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect } from "react"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
export function ConfigLoader() {
|
|
||||||
const { fetchSettings } = useStore()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchSettings()
|
|
||||||
}, [fetchSettings])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
return (
|
|
||||||
<footer className="py-6 px-4 border-t border-gray-800 pb-24">
|
|
||||||
<div className="max-w-xs mx-auto text-center">
|
|
||||||
<p className="text-white text-sm font-medium mb-1">一场SOUL的创业实验场</p>
|
|
||||||
<p className="text-gray-500 text-xs">© 2025 卡若 · 每日直播 06:00-09:00</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { usePathname } from "next/navigation"
|
|
||||||
import { BottomNav } from "@/components/bottom-nav"
|
|
||||||
import { ConfigLoader } from "@/components/config-loader"
|
|
||||||
|
|
||||||
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const isAdmin = pathname?.startsWith("/admin")
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-100 text-gray-900 font-sans">
|
|
||||||
<ConfigLoader />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-[430px] min-h-screen bg-[#0a1628] shadow-2xl relative font-sans antialiased">
|
|
||||||
<ConfigLoader />
|
|
||||||
{children}
|
|
||||||
<BottomNav />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { X, Phone, User, Gift } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
interface AuthModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
defaultTab?: "login" | "register"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalProps) {
|
|
||||||
const [tab, setTab] = useState<"login" | "register">(defaultTab)
|
|
||||||
const [phone, setPhone] = useState("")
|
|
||||||
const [code, setCode] = useState("")
|
|
||||||
const [nickname, setNickname] = useState("")
|
|
||||||
const [referralCode, setReferralCode] = useState("")
|
|
||||||
const [error, setError] = useState("")
|
|
||||||
const [codeSent, setCodeSent] = useState(false)
|
|
||||||
|
|
||||||
const { login, register } = useStore()
|
|
||||||
|
|
||||||
const handleSendCode = () => {
|
|
||||||
if (phone.length !== 11) {
|
|
||||||
setError("请输入正确的手机号")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Simulate sending verification code
|
|
||||||
setCodeSent(true)
|
|
||||||
setError("")
|
|
||||||
alert("验证码已发送,测试验证码: 123456")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
setError("")
|
|
||||||
const success = await login(phone, code)
|
|
||||||
if (success) {
|
|
||||||
onClose()
|
|
||||||
} else {
|
|
||||||
setError("验证码错误或用户不存在,请先注册")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRegister = async () => {
|
|
||||||
setError("")
|
|
||||||
if (!nickname.trim()) {
|
|
||||||
setError("请输入昵称")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const success = await register(phone, nickname, referralCode || undefined)
|
|
||||||
if (success) {
|
|
||||||
onClose()
|
|
||||||
} else {
|
|
||||||
setError("该手机号已注册")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex border-b border-gray-700/50">
|
|
||||||
<button
|
|
||||||
onClick={() => setTab("login")}
|
|
||||||
className={`flex-1 py-4 text-center transition-colors ${
|
|
||||||
tab === "login" ? "text-white border-b-2 border-[#38bdac]" : "text-gray-400 hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
登录
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTab("register")}
|
|
||||||
className={`flex-1 py-4 text-center transition-colors ${
|
|
||||||
tab === "register" ? "text-white border-b-2 border-[#38bdac]" : "text-gray-400 hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
注册
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6">
|
|
||||||
{tab === "login" ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">手机号</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="tel"
|
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
|
||||||
placeholder="请输入手机号"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
maxLength={11}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">验证码</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
placeholder="请输入验证码"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
maxLength={6}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleSendCode}
|
|
||||||
disabled={codeSent}
|
|
||||||
className="whitespace-nowrap border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
|
||||||
>
|
|
||||||
{codeSent ? "已发送" : "获取验证码"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
|
||||||
|
|
||||||
<Button onClick={handleLogin} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5">
|
|
||||||
登录
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">手机号</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="tel"
|
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
|
||||||
placeholder="请输入手机号"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
maxLength={11}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">昵称</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={nickname}
|
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
|
||||||
placeholder="请输入昵称"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">验证码</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
placeholder="请输入验证码"
|
|
||||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
maxLength={6}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleSendCode}
|
|
||||||
disabled={codeSent}
|
|
||||||
className="whitespace-nowrap border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
|
||||||
>
|
|
||||||
{codeSent ? "已发送" : "获取验证码"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 text-sm mb-2">邀请码 (选填)</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={referralCode}
|
|
||||||
onChange={(e) => setReferralCode(e.target.value)}
|
|
||||||
placeholder="填写邀请码可获得优惠"
|
|
||||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
|
||||||
|
|
||||||
<Button onClick={handleRegister} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5">
|
|
||||||
注册
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { X, MessageCircle, Users, Music } from "lucide-react"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import Image from "next/image"
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
|
|
||||||
interface QRCodeModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QRCodeModal({ isOpen, onClose }: QRCodeModalProps) {
|
|
||||||
const { settings, getLiveQRCodeUrl } = useStore()
|
|
||||||
const [isJoining, setIsJoining] = useState(false)
|
|
||||||
const [qrCodeUrl, setQrCodeUrl] = useState("/images/party-group-qr.png") // Default fallback
|
|
||||||
|
|
||||||
// Fetch config on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchConfig = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/config')
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.marketing?.partyGroup?.qrCode) {
|
|
||||||
setQrCodeUrl(data.marketing.partyGroup.qrCode)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to load QR config", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchConfig()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
const handleJoin = () => {
|
|
||||||
setIsJoining(true)
|
|
||||||
// 获取活码随机URL
|
|
||||||
const url = getLiveQRCodeUrl("party-group")
|
|
||||||
if (url) {
|
|
||||||
window.open(url, "_blank")
|
|
||||||
}
|
|
||||||
setTimeout(() => setIsJoining(false), 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-md" onClick={onClose} />
|
|
||||||
|
|
||||||
{/* iOS Style Modal */}
|
|
||||||
<div className="relative w-full max-w-[320px] bg-[#1a1a1a] rounded-3xl border border-white/10 overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
|
||||||
|
|
||||||
{/* Header Background Effect */}
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-[#7000ff]/20 to-transparent pointer-events-none" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 p-2 text-white/50 hover:text-white transition-colors z-10 bg-black/20 rounded-full backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="p-8 flex flex-col items-center text-center relative">
|
|
||||||
|
|
||||||
{/* Icon Badge */}
|
|
||||||
<div className="w-16 h-16 mb-6 rounded-full bg-gradient-to-tr from-[#7000ff] to-[#bd00ff] p-[2px] shadow-lg shadow-purple-500/20">
|
|
||||||
<div className="w-full h-full rounded-full bg-[#1a1a1a] flex items-center justify-center">
|
|
||||||
<Music className="w-8 h-8 text-[#bd00ff]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-xl font-bold text-white mb-2 tracking-tight">
|
|
||||||
Soul 创业派对
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-6">
|
|
||||||
<span className="px-2 py-0.5 rounded-full bg-white/10 text-[10px] text-white/70 border border-white/5">
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
<p className="text-white/60 text-xs">
|
|
||||||
{settings.authorInfo?.liveTime || "06:00-09:00"} · {settings.authorInfo?.name || "卡若"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR Code Container - Enhanced Visibility */}
|
|
||||||
<div className="bg-white p-3 rounded-2xl shadow-xl mb-6 transform transition-transform hover:scale-105 duration-300">
|
|
||||||
<div className="relative w-48 h-48">
|
|
||||||
<Image
|
|
||||||
src={qrCodeUrl}
|
|
||||||
alt="派对群二维码"
|
|
||||||
fill
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-white/40 text-xs mb-6 px-4">
|
|
||||||
扫码加入私域流量实战群<br/>获取《私域运营100问》
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleJoin}
|
|
||||||
disabled={isJoining}
|
|
||||||
className="w-full bg-gradient-to-r from-[#7000ff] to-[#bd00ff] hover:opacity-90 text-white font-medium rounded-xl h-12 shadow-lg shadow-purple-900/20 border-0 transition-all active:scale-95"
|
|
||||||
>
|
|
||||||
{isJoining ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Users className="w-4 h-4 animate-pulse" />
|
|
||||||
跳转中...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<MessageCircle className="w-4 h-4" />
|
|
||||||
立即加入
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
import { useState } from "react"
|
|
||||||
import { X, CheckCircle, Bitcoin, Globe, Copy, Check } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
const WechatIcon = () => (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
|
|
||||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.406-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const AlipayIcon = () => (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
|
|
||||||
<path d="M8.77 20.62l9.92-4.33c-.12-.33-.24-.66-.38-.99-.14-.33-.3-.66-.47-.99H8.08c-2.2 0-3.99-1.79-3.99-3.99V8.08c0-2.2 1.79-3.99 3.99-3.99h7.84c2.2 0 3.99 1.79 3.99 3.99v2.24h-8.66c-.55 0-1 .45-1 1s.45 1 1 1h10.66c-.18 1.73-.71 3.36-1.53 4.83l-2.76 1.2c-.74-1.69-1.74-3.24-2.93-4.6-.52-.59-1.11-1.13-1.76-1.59H4.09v4.24c0 2.2 1.79 3.99 3.99 3.99h.69v.23z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
type PaymentMethod = "wechat" | "alipay" | "usdt" | "paypal"
|
|
||||||
|
|
||||||
interface PaymentModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
type: "section" | "fullbook"
|
|
||||||
sectionId?: string
|
|
||||||
sectionTitle?: string
|
|
||||||
amount: number
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, amount, onSuccess }: PaymentModalProps) {
|
|
||||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("wechat")
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
|
||||||
const [isSuccess, setIsSuccess] = useState(false)
|
|
||||||
const [showPaymentDetails, setShowPaymentDetails] = useState(false)
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const { purchaseSection, purchaseFullBook, user, settings } = useStore()
|
|
||||||
|
|
||||||
const paymentConfig = settings?.paymentMethods || {
|
|
||||||
wechat: { enabled: true, qrCode: "", account: "" },
|
|
||||||
alipay: { enabled: true, qrCode: "", account: "" },
|
|
||||||
usdt: { enabled: true, network: "TRC20", address: "", exchangeRate: 7.2 },
|
|
||||||
paypal: { enabled: false, email: "", exchangeRate: 7.2 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const usdtAmount = (amount / (paymentConfig.usdt.exchangeRate || 7.2)).toFixed(2)
|
|
||||||
const paypalAmount = (amount / (paymentConfig.paypal.exchangeRate || 7.2)).toFixed(2)
|
|
||||||
|
|
||||||
const handleCopyAddress = (address: string) => {
|
|
||||||
navigator.clipboard.writeText(address)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePayment = async () => {
|
|
||||||
if (paymentMethod === "usdt" || paymentMethod === "paypal" || paymentMethod === "wechat" || paymentMethod === "alipay") {
|
|
||||||
setShowPaymentDetails(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(true)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
||||||
|
|
||||||
let success = false
|
|
||||||
if (type === "section" && sectionId) {
|
|
||||||
success = await purchaseSection(sectionId, sectionTitle, paymentMethod)
|
|
||||||
} else if (type === "fullbook") {
|
|
||||||
success = await purchaseFullBook(paymentMethod)
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(false)
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
setIsSuccess(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
setIsSuccess(false)
|
|
||||||
}, 1500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmCryptoPayment = async () => {
|
|
||||||
setIsProcessing(true)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
|
|
||||||
let success = false
|
|
||||||
if (type === "section" && sectionId) {
|
|
||||||
success = await purchaseSection(sectionId, sectionTitle, paymentMethod)
|
|
||||||
} else if (type === "fullbook") {
|
|
||||||
success = await purchaseFullBook(paymentMethod)
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(false)
|
|
||||||
setShowPaymentDetails(false)
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
setIsSuccess(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
setIsSuccess(false)
|
|
||||||
}, 1500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
const paymentMethods: {
|
|
||||||
id: PaymentMethod
|
|
||||||
name: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
color: string
|
|
||||||
enabled: boolean
|
|
||||||
extra?: string
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
id: "wechat",
|
|
||||||
name: "微信支付",
|
|
||||||
icon: <WechatIcon />,
|
|
||||||
color: "bg-[#07C160]",
|
|
||||||
enabled: paymentConfig.wechat.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "alipay",
|
|
||||||
name: "支付宝",
|
|
||||||
icon: <AlipayIcon />,
|
|
||||||
color: "bg-[#1677FF]",
|
|
||||||
enabled: paymentConfig.alipay.enabled,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "usdt",
|
|
||||||
name: `USDT (${paymentConfig.usdt.network || "TRC20"})`,
|
|
||||||
icon: <Bitcoin className="w-5 h-5" />,
|
|
||||||
color: "bg-[#26A17B]",
|
|
||||||
enabled: paymentConfig.usdt.enabled,
|
|
||||||
extra: `≈ $${usdtAmount}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "paypal",
|
|
||||||
name: "PayPal",
|
|
||||||
icon: <Globe className="w-5 h-5" />,
|
|
||||||
color: "bg-[#003087]",
|
|
||||||
enabled: paymentConfig.paypal.enabled,
|
|
||||||
extra: `≈ $${paypalAmount}`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const availableMethods = paymentMethods.filter((m) => m.enabled)
|
|
||||||
|
|
||||||
// Payment details view
|
|
||||||
if (showPaymentDetails) {
|
|
||||||
const isCrypto = paymentMethod === "usdt"
|
|
||||||
const isPayPal = paymentMethod === "paypal"
|
|
||||||
const isWechat = paymentMethod === "wechat"
|
|
||||||
const isAlipay = paymentMethod === "alipay"
|
|
||||||
|
|
||||||
let title = ""
|
|
||||||
let address = ""
|
|
||||||
let displayAmount = `¥${amount.toFixed(2)}`
|
|
||||||
let qrCodeUrl = ""
|
|
||||||
|
|
||||||
if (isCrypto) {
|
|
||||||
title = "USDT支付"
|
|
||||||
address = paymentConfig.usdt.address
|
|
||||||
displayAmount = `$${usdtAmount} USDT`
|
|
||||||
} else if (isPayPal) {
|
|
||||||
title = "PayPal支付"
|
|
||||||
address = paymentConfig.paypal.email
|
|
||||||
displayAmount = `$${paypalAmount} USD`
|
|
||||||
} else if (isWechat) {
|
|
||||||
title = "微信支付"
|
|
||||||
qrCodeUrl = paymentConfig.wechat.qrCode || "/images/wechat-pay.png"
|
|
||||||
} else if (isAlipay) {
|
|
||||||
title = "支付宝支付"
|
|
||||||
qrCodeUrl = paymentConfig.alipay.qrCode || "/images/alipay.png"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
||||||
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
|
|
||||||
<button onClick={onClose} className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white z-10">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-4 text-center">{title}</h3>
|
|
||||||
|
|
||||||
<div className="bg-[#0a1628] rounded-xl p-4 mb-4 text-center">
|
|
||||||
<p className="text-gray-400 text-sm mb-2">支付金额</p>
|
|
||||||
<p className="text-3xl font-bold text-[#38bdac]">{displayAmount}</p>
|
|
||||||
{(isCrypto || isPayPal) && <p className="text-gray-500 text-sm">≈ ¥{amount.toFixed(2)}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(isWechat || isAlipay) ? (
|
|
||||||
<div className="flex flex-col items-center justify-center mb-6">
|
|
||||||
<div className="bg-white p-2 rounded-xl mb-4">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={qrCodeUrl} alt={title} className="w-48 h-48 object-contain" />
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400 text-sm">请使用{title === "微信支付" ? "微信" : "支付宝"}扫码支付</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-[#0a1628] rounded-xl p-4 mb-4">
|
|
||||||
<p className="text-gray-400 text-sm mb-2">
|
|
||||||
{isCrypto ? `收款地址 (${paymentConfig.usdt.network})` : "PayPal账户"}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-white text-sm break-all flex-1 font-mono">
|
|
||||||
{address || "请联系客服获取"}
|
|
||||||
</p>
|
|
||||||
{address && (
|
|
||||||
<button onClick={() => handleCopyAddress(address)} className="text-[#38bdac] hover:text-[#4fd4c4]">
|
|
||||||
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-orange-500/10 border border-orange-500/30 rounded-xl p-4 mb-6">
|
|
||||||
<p className="text-orange-400 text-sm text-center">
|
|
||||||
支付完成后,请点击下方"我已支付"按钮,<br/>系统将自动开通阅读权限
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowPaymentDetails(false)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1 border-gray-600 text-white hover:bg-gray-700/50 bg-transparent"
|
|
||||||
>
|
|
||||||
返回
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={confirmCryptoPayment}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="flex-1 bg-[#38bdac] hover:bg-[#2da396] text-white"
|
|
||||||
>
|
|
||||||
{isProcessing ? "处理中..." : "已完成支付"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
||||||
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isSuccess ? (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-[#38bdac]/20 flex items-center justify-center">
|
|
||||||
<CheckCircle className="w-10 h-10 text-[#38bdac]" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">支付成功</h3>
|
|
||||||
<p className="text-gray-400">{type === "fullbook" ? "您已解锁全部内容" : "您已解锁本节内容"}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="p-6 border-b border-gray-700/50">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">确认支付</h3>
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
{type === "fullbook" ? "购买整本书,解锁全部内容" : `购买: ${sectionTitle}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border-b border-gray-700/50 text-center">
|
|
||||||
<p className="text-gray-400 text-sm mb-1">支付金额</p>
|
|
||||||
<p className="text-4xl font-bold text-white">¥{amount.toFixed(2)}</p>
|
|
||||||
{(paymentMethod === "usdt" || paymentMethod === "paypal") && (
|
|
||||||
<p className="text-[#38bdac] text-sm mt-1">
|
|
||||||
≈ ${paymentMethod === "usdt" ? usdtAmount : paypalAmount} USD
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{user?.referredBy && (
|
|
||||||
<p className="text-[#38bdac] text-sm mt-2">
|
|
||||||
通过邀请注册,{settings?.distributorShare || 90}%将返还给推荐人
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 space-y-3">
|
|
||||||
<p className="text-gray-400 text-sm mb-3">选择支付方式</p>
|
|
||||||
{availableMethods.map((method) => (
|
|
||||||
<button
|
|
||||||
key={method.id}
|
|
||||||
onClick={() => setPaymentMethod(method.id)}
|
|
||||||
className={`w-full p-4 rounded-xl border flex items-center gap-4 transition-all ${
|
|
||||||
paymentMethod === method.id
|
|
||||||
? "border-[#38bdac] bg-[#38bdac]/10"
|
|
||||||
: "border-gray-700 hover:border-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${method.color} flex items-center justify-center text-white`}>
|
|
||||||
{method.icon}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<span className="text-white">{method.name}</span>
|
|
||||||
{method.extra && <span className="text-gray-400 text-sm ml-2">{method.extra}</span>}
|
|
||||||
</div>
|
|
||||||
{paymentMethod === method.id && <CheckCircle className="w-5 h-5 text-[#38bdac]" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{availableMethods.length === 0 && <p className="text-gray-500 text-center py-4">暂无可用支付方式</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 pt-0">
|
|
||||||
<Button
|
|
||||||
onClick={handlePayment}
|
|
||||||
disabled={isProcessing || availableMethods.length === 0}
|
|
||||||
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-6 text-lg"
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
||||||
处理中...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
`确认支付 ¥${amount.toFixed(2)}`
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<p className="text-gray-500 text-xs text-center mt-3">支付即表示同意《用户协议》和《隐私政策》</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
interface PosterModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
referralLink: string
|
|
||||||
referralCode: string
|
|
||||||
nickname: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PosterModal({ isOpen, onClose, referralLink, referralCode, nickname }: PosterModalProps) {
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
// Use a public QR code API
|
|
||||||
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(referralLink)}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose} />
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm bg-white rounded-xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-2 right-2 p-1.5 bg-black/20 rounded-full text-white hover:bg-black/40 z-10"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Poster Content */}
|
|
||||||
<div className="bg-gradient-to-br from-indigo-900 to-purple-900 text-white p-6 flex flex-col items-center text-center relative overflow-hidden">
|
|
||||||
{/* Decorative circles */}
|
|
||||||
<div className="absolute top-0 left-0 w-32 h-32 bg-white/10 rounded-full -translate-x-1/2 -translate-y-1/2 blur-2xl" />
|
|
||||||
<div className="absolute bottom-0 right-0 w-40 h-40 bg-pink-500/20 rounded-full translate-x-1/3 translate-y-1/3 blur-2xl" />
|
|
||||||
|
|
||||||
<div className="relative z-10 w-full flex flex-col items-center">
|
|
||||||
{/* Book Title */}
|
|
||||||
<h2 className="text-xl font-bold mb-1 leading-tight text-white">一场SOUL的<br/>创业实验场</h2>
|
|
||||||
<p className="text-white/80 text-xs mb-6">真实商业故事 · 55个案例 · 每日更新</p>
|
|
||||||
|
|
||||||
{/* Cover Image Placeholder */}
|
|
||||||
<div className="w-32 h-44 bg-gray-200 rounded shadow-lg mb-6 overflow-hidden relative">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src="/images/image.png" alt="Book Cover" className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recommender Info */}
|
|
||||||
<div className="flex items-center gap-2 mb-4 bg-white/10 px-3 py-1.5 rounded-full backdrop-blur-sm">
|
|
||||||
<span className="text-xs text-white">推荐人: {nickname}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR Code Section */}
|
|
||||||
<div className="bg-white p-2 rounded-lg shadow-lg mb-2">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={qrCodeUrl} alt="QR Code" className="w-32 h-32" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-white/60 mb-1">长按识别二维码试读</p>
|
|
||||||
<p className="text-xs font-mono tracking-wider text-white">邀请码: {referralCode}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Actions */}
|
|
||||||
<div className="p-4 bg-gray-50 flex flex-col gap-2">
|
|
||||||
<p className="text-center text-xs text-gray-500 mb-1">
|
|
||||||
长按上方图片保存,或截图分享
|
|
||||||
</p>
|
|
||||||
<Button onClick={onClose} className="w-full" variant="outline">
|
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Share2 } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
interface ReferralShareProps {
|
|
||||||
sectionTitle: string
|
|
||||||
fullBookPrice: number
|
|
||||||
distributorShare: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReferralShare({ sectionTitle, fullBookPrice, distributorShare }: ReferralShareProps) {
|
|
||||||
const { user } = useStore()
|
|
||||||
|
|
||||||
const handleShare = async () => {
|
|
||||||
const url = user?.referralCode ? `${window.location.href}?ref=${user.referralCode}` : window.location.href
|
|
||||||
const shareData = {
|
|
||||||
title: sectionTitle,
|
|
||||||
text: `来自Soul派对房的真实商业故事: ${sectionTitle}`,
|
|
||||||
url: url,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (navigator.share && navigator.canShare && navigator.canShare(shareData)) {
|
|
||||||
await navigator.share(shareData)
|
|
||||||
} else {
|
|
||||||
navigator.clipboard.writeText(url)
|
|
||||||
alert(
|
|
||||||
`链接已复制!分享后他人购买,你可获得${distributorShare}%返利 (¥${((fullBookPrice * distributorShare) / 100).toFixed(1)})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sharing:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-gray-400 hover:text-white"
|
|
||||||
onClick={handleShare}
|
|
||||||
>
|
|
||||||
<Share2 className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { X, Wallet, CheckCircle } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
interface WithdrawalModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
availableAmount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WithdrawalModal({ isOpen, onClose, availableAmount }: WithdrawalModalProps) {
|
|
||||||
const { requestWithdrawal } = useStore()
|
|
||||||
const [amount, setAmount] = useState<string>("")
|
|
||||||
const [method, setMethod] = useState<"wechat" | "alipay">("wechat")
|
|
||||||
const [account, setAccount] = useState("")
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [isSuccess, setIsSuccess] = useState(false)
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const amountNum = parseFloat(amount)
|
|
||||||
if (isNaN(amountNum) || amountNum <= 0 || amountNum > availableAmount) {
|
|
||||||
alert("请输入有效的提现金额")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!account || !name) {
|
|
||||||
alert("请填写完整的提现信息")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true)
|
|
||||||
|
|
||||||
// Simulate API delay
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
|
|
||||||
requestWithdrawal(amountNum, method, account, name)
|
|
||||||
|
|
||||||
setIsSubmitting(false)
|
|
||||||
setIsSuccess(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsSuccess(false)
|
|
||||||
setAmount("")
|
|
||||||
setAccount("")
|
|
||||||
setName("")
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={handleClose} />
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm bg-white rounded-xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="absolute top-2 right-2 p-1.5 bg-black/10 rounded-full text-gray-500 hover:bg-black/20 z-10"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isSuccess ? (
|
|
||||||
<div className="p-8 flex flex-col items-center text-center">
|
|
||||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
|
||||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-2">申请提交成功</h3>
|
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
|
||||||
您的提现申请已提交,预计1-3个工作日内到账。
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleClose} className="w-full bg-green-600 hover:bg-green-700 text-white">
|
|
||||||
完成
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit} className="p-6">
|
|
||||||
<div className="flex items-center gap-2 mb-6">
|
|
||||||
<Wallet className="w-5 h-5 text-indigo-600" />
|
|
||||||
<h3 className="text-lg font-bold text-gray-900">申请提现</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="amount">提现金额 (可提现: ¥{availableAmount.toFixed(2)})</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">¥</span>
|
|
||||||
<Input
|
|
||||||
id="amount"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
max={availableAmount}
|
|
||||||
step="0.01"
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
|
||||||
className="pl-7"
|
|
||||||
placeholder="最低10元"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>提现方式</Label>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMethod("wechat")}
|
|
||||||
className={`flex-1 py-2 px-4 rounded-lg border text-sm font-medium transition-colors ${
|
|
||||||
method === "wechat"
|
|
||||||
? "border-green-600 bg-green-50 text-green-700"
|
|
||||||
: "border-gray-200 hover:bg-gray-50 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
微信支付
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMethod("alipay")}
|
|
||||||
className={`flex-1 py-2 px-4 rounded-lg border text-sm font-medium transition-colors ${
|
|
||||||
method === "alipay"
|
|
||||||
? "border-blue-600 bg-blue-50 text-blue-700"
|
|
||||||
: "border-gray-200 hover:bg-gray-50 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
支付宝
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="account">{method === "wechat" ? "微信号" : "支付宝账号"}</Label>
|
|
||||||
<Input
|
|
||||||
id="account"
|
|
||||||
value={account}
|
|
||||||
onChange={(e) => setAccount(e.target.value)}
|
|
||||||
placeholder={method === "wechat" ? "请输入微信号" : "请输入支付宝账号"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">真实姓名</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="请输入收款人真实姓名"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white"
|
|
||||||
disabled={isSubmitting || !amount || !account || !name}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "提交中..." : "确认提现"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Image from "next/image"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
export function PartyGroupSection() {
|
|
||||||
const { settings, getLiveQRCodeUrl } = useStore()
|
|
||||||
|
|
||||||
const handleJoin = () => {
|
|
||||||
const url = getLiveQRCodeUrl("party-group")
|
|
||||||
if (url) {
|
|
||||||
window.open(url, "_blank")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="py-8 px-4 bg-app-bg">
|
|
||||||
<div className="max-w-sm mx-auto">
|
|
||||||
<div className="bg-app-card/40 backdrop-blur-md rounded-xl p-5 border border-app-border text-center">
|
|
||||||
<p className="text-app-text-muted text-xs mb-3">
|
|
||||||
每天 {settings.authorInfo?.liveTime || "06:00-09:00"} · 免费分享
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* QR Code - smaller */}
|
|
||||||
<div className="bg-white rounded-lg p-2 mb-3 inline-block">
|
|
||||||
<Image src="/images/image.png" alt="派对群二维码" width={120} height={120} className="mx-auto" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-app-text text-sm font-medium mb-1">扫码加入派对群</p>
|
|
||||||
<p className="text-app-text-muted text-xs">长按识别二维码</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { X, CheckCircle, Bitcoin, Globe, Copy, Check, QrCode } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
const WechatIcon = () => (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
|
|
||||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.269-.03-.406-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const AlipayIcon = () => (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
|
|
||||||
<path d="M20.422 13.066c-.198-.07-.405-.137-.62-.202.107-.263.204-.534.29-.814h-3.32v-.93h3.927v-.627h-3.927v-1.18h-1.637c.07-.138.131-.28.184-.425l-1.483-.326a4.091 4.091 0 0 1-.405.75h-2.78v1.181H7.72v.627h2.932v.93H7.205v.652h5.784a9.296 9.296 0 0 1-.608.814 13.847 13.847 0 0 0-2.76-.93l-.483.652c1.038.273 1.96.608 2.766 1.008a8.483 8.483 0 0 1-3.603 1.484l.43.652c1.71-.378 3.103-1.03 4.18-1.957.665.395 1.223.835 1.67 1.32l.608-.652c-.44-.43-.984-.836-1.637-1.215a9.6 9.6 0 0 0 .72-.93c.182-.264.345-.53.488-.798.587.168 1.12.35 1.598.544 1.956.8 2.82 1.614 2.82 2.665 0 .727-.587 1.277-2.21 1.277-1.193 0-2.524-.203-3.996-.609l-.103.75c1.445.378 2.843.567 4.196.567 2.158 0 3.204-.748 3.204-2.013 0-1.382-1.183-2.437-3.58-3.413z" />
|
|
||||||
<path d="M21.714 4H2.286A2.286 2.286 0 0 0 0 6.286v11.428A2.286 2.286 0 0 0 2.286 20h19.428A2.286 2.286 0 0 0 24 17.714V6.286A2.286 2.286 0 0 0 21.714 4zM2.286 5.143h19.428c.631 0 1.143.512 1.143 1.143v8.08c-.957-.454-2.222-.903-3.75-1.346a9.8 9.8 0 0 0 .607-2.02h-4.571V9.286h5.143V8.143h-5.143V6.286H13.43v1.857H8.286v1.143h5.143V11H8.286v1.143h6.356a11.54 11.54 0 0 1-.916 1.512 16.648 16.648 0 0 0-3.3-1.12l-.576.78c1.242.328 2.348.73 3.31 1.21a10.175 10.175 0 0 1-4.317 1.78l.514.78c2.048-.454 3.718-1.237 5.008-2.344.796.472 1.464 1 2.004 1.583l.726-.78c-.527-.516-1.179-.996-1.96-1.455.327-.407.627-.839.9-1.295.264-.447.495-.907.694-1.38.7.2 1.341.412 1.916.637 2.343.96 3.378 1.935 3.378 3.195 0 .872-.703 1.532-2.647 1.532-1.43 0-3.023-.244-4.786-.732l-.123.9c1.73.454 3.407.68 5.03.68 2.585 0 3.84-.899 3.84-2.416 0-1.166-.69-2.152-2.066-2.96v-.001c-.24-.14-.495-.276-.77-.408V6.286a1.143 1.143 0 0 0-1.143-1.143z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
type PaymentMethod = "wechat" | "alipay" | "usdt" | "paypal" | "stripe" | "bank"
|
|
||||||
|
|
||||||
interface PaymentModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
type: "section" | "fullbook"
|
|
||||||
sectionId?: string
|
|
||||||
sectionTitle?: string
|
|
||||||
amount: number
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaymentModal({ isOpen, onClose, type, sectionId, sectionTitle, amount, onSuccess }: PaymentModalProps) {
|
|
||||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("alipay")
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
|
||||||
const [isSuccess, setIsSuccess] = useState(false)
|
|
||||||
const [showQRCode, setShowQRCode] = useState(false)
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const { purchaseSection, purchaseFullBook, user, settings } = useStore()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setShowQRCode(false)
|
|
||||||
setIsSuccess(false)
|
|
||||||
setIsProcessing(false)
|
|
||||||
}
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
const paymentConfig = settings?.paymentMethods || {
|
|
||||||
wechat: { enabled: true, qrCode: "", account: "", groupQrCode: "" },
|
|
||||||
alipay: { enabled: true, qrCode: "", account: "" },
|
|
||||||
usdt: { enabled: true, network: "TRC20", address: "", exchangeRate: 7.2 },
|
|
||||||
paypal: { enabled: false, email: "", exchangeRate: 7.2 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const usdtAmount = (amount / (paymentConfig.usdt?.exchangeRate || 7.2)).toFixed(2)
|
|
||||||
const paypalAmount = (amount / (paymentConfig.paypal?.exchangeRate || 7.2)).toFixed(2)
|
|
||||||
|
|
||||||
const handleCopyAddress = (address: string) => {
|
|
||||||
navigator.clipboard.writeText(address)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePayment = async () => {
|
|
||||||
// 直接显示支付二维码/详情页面
|
|
||||||
setShowQRCode(true)
|
|
||||||
|
|
||||||
// 如果有跳转链接,尝试打开
|
|
||||||
if (paymentMethod === "wechat" && paymentConfig.wechat?.qrCode) {
|
|
||||||
const link = paymentConfig.wechat.qrCode
|
|
||||||
if (link.startsWith("http") || link.startsWith("weixin://")) {
|
|
||||||
window.open(link, "_blank")
|
|
||||||
}
|
|
||||||
} else if (paymentMethod === "alipay" && paymentConfig.alipay?.qrCode) {
|
|
||||||
const link = paymentConfig.alipay.qrCode
|
|
||||||
if (link.startsWith("http") || link.startsWith("alipays://")) {
|
|
||||||
window.open(link, "_blank")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmPayment = async () => {
|
|
||||||
setIsProcessing(true)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
|
|
||||||
let success = false
|
|
||||||
if (type === "section" && sectionId) {
|
|
||||||
success = await purchaseSection(sectionId, sectionTitle, paymentMethod)
|
|
||||||
} else if (type === "fullbook") {
|
|
||||||
success = await purchaseFullBook(paymentMethod)
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(false)
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
setIsSuccess(true)
|
|
||||||
|
|
||||||
// 支付成功后跳转微信群
|
|
||||||
const groupUrl = paymentConfig.wechat?.groupQrCode
|
|
||||||
if (groupUrl) {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.open(groupUrl, "_blank")
|
|
||||||
}, 800)
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
setIsSuccess(false)
|
|
||||||
setShowQRCode(false)
|
|
||||||
}, 2500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
const paymentMethods: {
|
|
||||||
id: PaymentMethod
|
|
||||||
name: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
color: string
|
|
||||||
enabled: boolean
|
|
||||||
extra?: string
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
id: "wechat",
|
|
||||||
name: "微信支付",
|
|
||||||
icon: <WechatIcon />,
|
|
||||||
color: "bg-[#07C160]",
|
|
||||||
enabled: paymentConfig.wechat?.enabled ?? true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "alipay",
|
|
||||||
name: "支付宝",
|
|
||||||
icon: <AlipayIcon />,
|
|
||||||
color: "bg-[#1677FF]",
|
|
||||||
enabled: paymentConfig.alipay?.enabled ?? true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "usdt",
|
|
||||||
name: `USDT (${paymentConfig.usdt?.network || "TRC20"})`,
|
|
||||||
icon: <Bitcoin className="w-5 h-5" />,
|
|
||||||
color: "bg-[#26A17B]",
|
|
||||||
enabled: paymentConfig.usdt?.enabled ?? true,
|
|
||||||
extra: `≈ $${usdtAmount}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "paypal",
|
|
||||||
name: "PayPal",
|
|
||||||
icon: <Globe className="w-5 h-5" />,
|
|
||||||
color: "bg-[#003087]",
|
|
||||||
enabled: paymentConfig.paypal?.enabled ?? false,
|
|
||||||
extra: `≈ $${paypalAmount}`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const availableMethods = paymentMethods.filter((m) => m.enabled)
|
|
||||||
|
|
||||||
// 二维码/详情页面
|
|
||||||
if (showQRCode) {
|
|
||||||
const isCrypto = paymentMethod === "usdt"
|
|
||||||
const isPaypal = paymentMethod === "paypal"
|
|
||||||
const isWechat = paymentMethod === "wechat"
|
|
||||||
const isAlipay = paymentMethod === "alipay"
|
|
||||||
|
|
||||||
let address = ""
|
|
||||||
let displayAmount = `¥${amount.toFixed(2)}`
|
|
||||||
let title = "扫码支付"
|
|
||||||
let hint = "支付完成后,请点击下方已完成支付按钮"
|
|
||||||
let qrCodeUrl = ""
|
|
||||||
|
|
||||||
if (isCrypto) {
|
|
||||||
address = paymentConfig.usdt?.address || ""
|
|
||||||
displayAmount = `$${usdtAmount} USDT`
|
|
||||||
title = "USDT支付"
|
|
||||||
hint = "请转账到以下地址,完成后点击确认"
|
|
||||||
} else if (isPaypal) {
|
|
||||||
address = paymentConfig.paypal?.email || ""
|
|
||||||
displayAmount = `$${paypalAmount} USD`
|
|
||||||
title = "PayPal支付"
|
|
||||||
hint = "请转账到以下PayPal账户"
|
|
||||||
} else if (isWechat) {
|
|
||||||
title = "微信支付"
|
|
||||||
qrCodeUrl = paymentConfig.wechat?.qrCode || ""
|
|
||||||
hint = "请使用微信扫码支付"
|
|
||||||
} else if (isAlipay) {
|
|
||||||
title = "支付宝支付"
|
|
||||||
qrCodeUrl = paymentConfig.alipay?.qrCode || ""
|
|
||||||
hint = "请使用支付宝扫码支付"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
|
||||||
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 shadow-2xl overflow-hidden">
|
|
||||||
<button onClick={onClose} className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white z-10">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="p-6 pt-12">
|
|
||||||
{/* 标题 */}
|
|
||||||
<h3 className="text-xl font-semibold text-white text-center mb-4">{title}</h3>
|
|
||||||
|
|
||||||
{/* 金额显示 */}
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<p className="text-3xl font-bold text-[#38bdac]">{displayAmount}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR Code Display */}
|
|
||||||
{(isWechat || isAlipay) && (
|
|
||||||
<div className="flex flex-col items-center mb-6">
|
|
||||||
<div className="w-48 h-48 bg-white rounded-xl p-3 mb-4 flex items-center justify-center">
|
|
||||||
{qrCodeUrl ? (
|
|
||||||
<img
|
|
||||||
src={qrCodeUrl || "/placeholder.svg"}
|
|
||||||
alt="支付二维码"
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center text-gray-400">
|
|
||||||
<QrCode className="w-16 h-16 mb-2" />
|
|
||||||
<span className="text-sm">请在后台配置收款码</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400 text-sm">{hint}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Crypto/PayPal Address */}
|
|
||||||
{(isCrypto || isPaypal) && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="bg-[#0a1628] rounded-xl p-4 border border-gray-700/30">
|
|
||||||
<p className="text-gray-400 text-sm mb-2">
|
|
||||||
{isCrypto ? `收款地址 (${paymentConfig.usdt?.network})` : "PayPal账户"}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-white text-sm break-all flex-1 font-mono">{address || "请在后台配置收款地址"}</p>
|
|
||||||
{address && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopyAddress(address)}
|
|
||||||
className="text-[#38bdac] hover:text-[#4fd4c4]"
|
|
||||||
>
|
|
||||||
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-500 text-sm mt-2 text-center">{hint}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 提示信息 */}
|
|
||||||
<div className="bg-[#38bdac]/10 border border-[#38bdac]/30 rounded-xl p-3 mb-4">
|
|
||||||
<p className="text-[#38bdac] text-sm text-center">支付完成后,系统将自动开通阅读权限</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowQRCode(false)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1 border-gray-600 text-white hover:bg-gray-700/50 bg-transparent"
|
|
||||||
>
|
|
||||||
返回
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={confirmPayment}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="flex-1 bg-[#38bdac] hover:bg-[#2da396] text-white"
|
|
||||||
>
|
|
||||||
{isProcessing ? "处理中..." : "已完成支付"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 支付成功页面
|
|
||||||
if (isSuccess) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
|
||||||
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<div className="w-20 h-20 mx-auto mb-4 rounded-full bg-[#38bdac]/20 flex items-center justify-center">
|
|
||||||
<CheckCircle className="w-10 h-10 text-[#38bdac]" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">支付成功</h3>
|
|
||||||
<p className="text-gray-400">{type === "fullbook" ? "您已解锁全部内容" : "您已解锁本节内容"}</p>
|
|
||||||
{paymentConfig.wechat?.groupQrCode && <p className="text-[#07C160] text-sm mt-4">正在跳转到微信群...</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主支付选择页面
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
|
||||||
<div className="relative w-full max-w-md bg-[#0f2137] rounded-2xl border border-gray-700/50 shadow-2xl overflow-hidden max-h-[90vh] overflow-y-auto">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors z-10"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-6 pt-12 border-b border-gray-700/50">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-1">确认支付</h3>
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
{type === "fullbook" ? "购买整本书,解锁全部内容" : `购买: ${sectionTitle}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Amount */}
|
|
||||||
<div className="p-6 border-b border-gray-700/50 text-center">
|
|
||||||
<p className="text-gray-400 text-sm mb-1">支付金额</p>
|
|
||||||
<p className="text-4xl font-bold text-white">¥{amount.toFixed(2)}</p>
|
|
||||||
{(paymentMethod === "usdt" || paymentMethod === "paypal") && (
|
|
||||||
<p className="text-[#38bdac] text-sm mt-1">≈ ${paymentMethod === "usdt" ? usdtAmount : paypalAmount} USD</p>
|
|
||||||
)}
|
|
||||||
{user?.referredBy && (
|
|
||||||
<p className="text-[#38bdac] text-sm mt-2">
|
|
||||||
通过邀请注册,{settings?.distributorShare || 90}%将返还给推荐人
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Payment Methods */}
|
|
||||||
<div className="p-6 space-y-3">
|
|
||||||
<p className="text-gray-400 text-sm mb-3">选择支付方式</p>
|
|
||||||
{availableMethods.map((method) => (
|
|
||||||
<button
|
|
||||||
key={method.id}
|
|
||||||
onClick={() => setPaymentMethod(method.id)}
|
|
||||||
className={`w-full p-4 rounded-xl border flex items-center gap-4 transition-all ${
|
|
||||||
paymentMethod === method.id
|
|
||||||
? "border-[#38bdac] bg-[#38bdac]/10"
|
|
||||||
: "border-gray-700 hover:border-gray-600 hover:bg-[#162840]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${method.color} flex items-center justify-center text-white`}>
|
|
||||||
{method.icon}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<span className="text-white">{method.name}</span>
|
|
||||||
{method.extra && <span className="text-gray-400 text-sm ml-2">{method.extra}</span>}
|
|
||||||
</div>
|
|
||||||
{paymentMethod === method.id && <CheckCircle className="w-5 h-5 text-[#38bdac]" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{availableMethods.length === 0 && <p className="text-gray-500 text-center py-4">暂无可用支付方式</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="p-6 pt-0">
|
|
||||||
<Button
|
|
||||||
onClick={handlePayment}
|
|
||||||
disabled={isProcessing || availableMethods.length === 0}
|
|
||||||
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-6 text-lg"
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
||||||
处理中...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
`确认支付 ¥${amount.toFixed(2)}`
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<p className="text-gray-500 text-xs text-center mt-3">支付即表示同意《用户协议》和《隐私政策》</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Zap, BookOpen } from "lucide-react"
|
|
||||||
import { getFullBookPrice, getAllSections } from "@/lib/book-data"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { AuthModal } from "./modules/auth/auth-modal"
|
|
||||||
import { PaymentModal } from "./modules/payment/payment-modal"
|
|
||||||
|
|
||||||
export function PurchaseSection() {
|
|
||||||
const [fullBookPrice, setFullBookPrice] = useState(9.9)
|
|
||||||
const [sectionsCount, setSectionsCount] = useState(55)
|
|
||||||
const [isAuthOpen, setIsAuthOpen] = useState(false)
|
|
||||||
const [isPaymentOpen, setIsPaymentOpen] = useState(false)
|
|
||||||
const { isLoggedIn } = useStore()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sections = getAllSections()
|
|
||||||
setSectionsCount(sections.length)
|
|
||||||
setFullBookPrice(getFullBookPrice(sections.length))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handlePurchase = () => {
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
setIsAuthOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsPaymentOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="py-8 px-4 bg-app-bg">
|
|
||||||
<div className="max-w-sm mx-auto">
|
|
||||||
{/* Pricing cards - stacked on mobile */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Single section */}
|
|
||||||
<div className="bg-app-card/60 backdrop-blur-xl rounded-xl p-4 border border-app-border">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BookOpen className="w-5 h-5 text-app-text-muted" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-app-text font-medium text-sm">单节购买</h3>
|
|
||||||
<p className="text-app-text-muted text-xs">按兴趣选择</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-xl font-bold text-app-text">¥1</span>
|
|
||||||
<span className="text-app-text-muted text-xs">/节</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Full book - highlighted */}
|
|
||||||
<div className="bg-gradient-to-br from-app-brand/20 to-app-card backdrop-blur-xl rounded-xl p-4 border border-app-brand/30 relative">
|
|
||||||
<span className="absolute -top-2 right-3 bg-app-brand text-white text-xs px-2 py-0.5 rounded-full">
|
|
||||||
推荐
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Zap className="w-5 h-5 text-app-brand" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-app-text font-medium text-sm">整本购买</h3>
|
|
||||||
<p className="text-app-text-muted text-xs">全部{sectionsCount}节 · 后续更新免费</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-xl font-bold text-app-text">¥{fullBookPrice.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handlePurchase}
|
|
||||||
className="w-full bg-app-brand hover:bg-app-brand-hover text-white rounded-lg h-10 text-sm"
|
|
||||||
>
|
|
||||||
立即购买
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dynamic pricing note */}
|
|
||||||
<p className="mt-3 text-center text-app-text-muted text-xs">动态定价: 每新增一章节,整本价格+¥1</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AuthModal isOpen={isAuthOpen} onClose={() => setIsAuthOpen(false)} />
|
|
||||||
<PaymentModal
|
|
||||||
isOpen={isPaymentOpen}
|
|
||||||
onClose={() => setIsPaymentOpen(false)}
|
|
||||||
type="fullbook"
|
|
||||||
amount={fullBookPrice}
|
|
||||||
onSuccess={() => window.location.reload()}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import Image from "next/image"
|
|
||||||
import { X, MessageCircle } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
|
|
||||||
interface QRCodeModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QRCodeModal({ isOpen, onClose }: QRCodeModalProps) {
|
|
||||||
const { settings, getLiveQRCodeUrl } = useStore()
|
|
||||||
const [isJoining, setIsJoining] = useState(false)
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
const qrCodeImage = "/images/image.png"
|
|
||||||
|
|
||||||
const handleJoin = () => {
|
|
||||||
setIsJoining(true)
|
|
||||||
// 获取活码随机URL
|
|
||||||
const url = getLiveQRCodeUrl("party-group")
|
|
||||||
if (url) {
|
|
||||||
window.open(url, "_blank")
|
|
||||||
}
|
|
||||||
setTimeout(() => setIsJoining(false), 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm bg-[#0f2137] rounded-2xl border border-gray-700/50 overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-white transition-colors z-10"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="p-6 text-center">
|
|
||||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[#38bdac]/20 flex items-center justify-center">
|
|
||||||
<MessageCircle className="w-6 h-6 text-[#38bdac]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">继续学习</h3>
|
|
||||||
<p className="text-gray-400 text-sm mb-6">
|
|
||||||
每天早上{settings.authorInfo?.liveTime || "06:00-09:00"},{settings.authorInfo?.name || "卡若"}
|
|
||||||
在派对房免费分享
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl p-4 mb-6">
|
|
||||||
<Image
|
|
||||||
src={qrCodeImage || "/placeholder.svg"}
|
|
||||||
alt="派对群二维码"
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm mb-4">扫码加入Soul派对群</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleJoin}
|
|
||||||
disabled={isJoining}
|
|
||||||
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white"
|
|
||||||
>
|
|
||||||
{isJoining ? "跳转中..." : "立即加入"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { ChevronRight } from "lucide-react"
|
|
||||||
import { Part } from "@/lib/book-data"
|
|
||||||
|
|
||||||
interface TableOfContentsProps {
|
|
||||||
parts: Part[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableOfContents({ parts }: TableOfContentsProps) {
|
|
||||||
return (
|
|
||||||
<section className="py-16 px-4">
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
{/* Section title */}
|
|
||||||
<h2 className="text-gray-400 text-sm mb-8">全书 {parts.length} 篇</h2>
|
|
||||||
|
|
||||||
{/* Parts list */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{parts.map((part) => (
|
|
||||||
<Link key={part.id} href={`/chapters?part=${part.id}`} className="block group">
|
|
||||||
<div className="bg-[#0f2137]/60 backdrop-blur-md rounded-xl p-6 border border-transparent hover:border-[#38bdac]/30 transition-all duration-300">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<span className="text-[#38bdac] font-mono text-lg">{part.number}</span>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white text-xl font-semibold mb-1 group-hover:text-[#38bdac] transition-colors">
|
|
||||||
{part.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-400">{part.subtitle}</p>
|
|
||||||
<p className="text-gray-500 text-sm mt-2">
|
|
||||||
{part.chapters.length} 章 · {part.chapters.reduce((acc, c) => acc + c.sections.length, 0)} 节
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-[#38bdac] transition-colors" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional content */}
|
|
||||||
<div className="mt-8 pt-8 border-t border-gray-700/50">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Link href="/chapters?section=preface" className="block group">
|
|
||||||
<div className="bg-[#0f2137]/40 backdrop-blur-md rounded-xl p-4 border border-transparent hover:border-[#38bdac]/30 transition-all">
|
|
||||||
<p className="text-gray-400 text-sm">序言</p>
|
|
||||||
<p className="text-white group-hover:text-[#38bdac] transition-colors">
|
|
||||||
为什么我每天早上6点在Soul开播?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Link href="/chapters?section=epilogue" className="block group">
|
|
||||||
<div className="bg-[#0f2137]/40 backdrop-blur-md rounded-xl p-4 border border-transparent hover:border-[#38bdac]/30 transition-all">
|
|
||||||
<p className="text-gray-400 text-sm">尾声</p>
|
|
||||||
<p className="text-white group-hover:text-[#38bdac] transition-colors">努力不是关键,选择才是</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import {
|
|
||||||
ThemeProvider as NextThemesProvider,
|
|
||||||
type ThemeProviderProps,
|
|
||||||
} from 'next-themes'
|
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { Slot } from '@radix-ui/react-slot'
|
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
|
||||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
|
||||||
secondary:
|
|
||||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
|
||||||
destructive:
|
|
||||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
|
||||||
outline:
|
|
||||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function Badge({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<'span'> &
|
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
||||||
const Comp = asChild ? Slot : 'span'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="badge"
|
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { Slot } from '@radix-ui/react-slot'
|
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
||||||
destructive:
|
|
||||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
|
||||||
outline:
|
|
||||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
|
||||||
secondary:
|
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
||||||
ghost:
|
|
||||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
|
||||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
|
||||||
icon: 'size-9',
|
|
||||||
'icon-sm': 'size-8',
|
|
||||||
'icon-lg': 'size-10',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
size: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<'button'> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : 'button'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Card.displayName = "Card"
|
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardHeader.displayName = "CardHeader"
|
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<h3
|
|
||||||
ref={ref}
|
|
||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardTitle.displayName = "CardTitle"
|
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardDescription.displayName = "CardDescription"
|
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
||||||
))
|
|
||||||
CardContent.displayName = "CardContent"
|
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardFooter.displayName = "CardFooter"
|
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
||||||
import { XIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
function Dialog({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
data-slot="dialog-overlay"
|
|
||||||
className={cn(
|
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DialogPortal data-slot="dialog-portal">
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
data-slot="dialog-content"
|
|
||||||
className={cn(
|
|
||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
data-slot="dialog-close"
|
|
||||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-header"
|
|
||||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-footer"
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
data-slot="dialog-title"
|
|
||||||
className={cn('text-lg leading-none font-semibold', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
data-slot="dialog-description"
|
|
||||||
className={cn('text-muted-foreground text-sm', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
data-slot="input"
|
|
||||||
className={cn(
|
|
||||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
||||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Input }
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Label }
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
))
|
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
))
|
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
))
|
|
||||||
SelectScrollDownButton.displayName =
|
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
position === "popper" &&
|
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
))
|
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
))
|
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectGroup,
|
|
||||||
SelectValue,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectContent,
|
|
||||||
SelectLabel,
|
|
||||||
SelectItem,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
function Slider({
|
|
||||||
className,
|
|
||||||
defaultValue,
|
|
||||||
value,
|
|
||||||
min = 0,
|
|
||||||
max = 100,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
|
||||||
const _values = React.useMemo(
|
|
||||||
() =>
|
|
||||||
Array.isArray(value)
|
|
||||||
? value
|
|
||||||
: Array.isArray(defaultValue)
|
|
||||||
? defaultValue
|
|
||||||
: [min, max],
|
|
||||||
[value, defaultValue, min, max],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SliderPrimitive.Root
|
|
||||||
data-slot="slider"
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
value={value}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
className={cn(
|
|
||||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SliderPrimitive.Track
|
|
||||||
data-slot="slider-track"
|
|
||||||
className={
|
|
||||||
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SliderPrimitive.Range
|
|
||||||
data-slot="slider-range"
|
|
||||||
className={
|
|
||||||
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SliderPrimitive.Track>
|
|
||||||
{Array.from({ length: _values.length }, (_, index) => (
|
|
||||||
<SliderPrimitive.Thumb
|
|
||||||
data-slot="slider-thumb"
|
|
||||||
key={index}
|
|
||||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SliderPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Slider }
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SwitchPrimitives.Root
|
|
||||||
className={cn(
|
|
||||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<SwitchPrimitives.Thumb
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SwitchPrimitives.Root>
|
|
||||||
))
|
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
|
||||||
|
|
||||||
export { Switch }
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
|
||||||
HTMLTableElement,
|
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className="relative w-full overflow-auto">
|
|
||||||
<table
|
|
||||||
ref={ref}
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
Table.displayName = "Table"
|
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
|
||||||
))
|
|
||||||
TableHeader.displayName = "TableHeader"
|
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tbody
|
|
||||||
ref={ref}
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableBody.displayName = "TableBody"
|
|
||||||
|
|
||||||
const TableFooter = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tfoot
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableFooter.displayName = "TableFooter"
|
|
||||||
|
|
||||||
const TableRow = React.forwardRef<
|
|
||||||
HTMLTableRowElement,
|
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tr
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableRow.displayName = "TableRow"
|
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<th
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableHead.displayName = "TableHead"
|
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<td
|
|
||||||
ref={ref}
|
|
||||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableCell.displayName = "TableCell"
|
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<
|
|
||||||
HTMLTableCaptionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<caption
|
|
||||||
ref={ref}
|
|
||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TableCaption.displayName = "TableCaption"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableBody,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
TableCaption,
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
|
||||||
|
|
||||||
const TabsList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const TabsContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
|
||||||
({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Textarea.displayName = "Textarea"
|
|
||||||
|
|
||||||
export { Textarea }
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { User, LogOut, BookOpen, Gift, Settings } from "lucide-react"
|
|
||||||
import { useStore } from "@/lib/store"
|
|
||||||
import { AuthModal } from "./modules/auth/auth-modal"
|
|
||||||
|
|
||||||
export function UserMenu() {
|
|
||||||
const [isAuthOpen, setIsAuthOpen] = useState(false)
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
|
||||||
const { user, isLoggedIn, logout } = useStore()
|
|
||||||
|
|
||||||
if (!isLoggedIn || !user) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsAuthOpen(true)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#38bdac]/10 text-[#38bdac] hover:bg-[#38bdac]/20 transition-colors"
|
|
||||||
>
|
|
||||||
<User className="w-4 h-4" />
|
|
||||||
<span>登录</span>
|
|
||||||
</button>
|
|
||||||
<AuthModal isOpen={isAuthOpen} onClose={() => setIsAuthOpen(false)} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#0f2137] border border-gray-700 hover:border-[#38bdac]/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center">
|
|
||||||
<User className="w-4 h-4 text-[#38bdac]" />
|
|
||||||
</div>
|
|
||||||
<span className="text-white text-sm max-w-[100px] truncate">{user.nickname}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isMenuOpen && (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setIsMenuOpen(false)} />
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-56 bg-[#0f2137] border border-gray-700 rounded-xl shadow-xl z-50 overflow-hidden">
|
|
||||||
{/* User info */}
|
|
||||||
<div className="p-4 border-b border-gray-700/50">
|
|
||||||
<p className="text-white font-medium">{user.nickname}</p>
|
|
||||||
<p className="text-gray-500 text-sm">{user.phone}</p>
|
|
||||||
{user.hasFullBook && (
|
|
||||||
<span className="inline-block mt-2 px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-xs rounded">
|
|
||||||
已购买全书
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Menu items */}
|
|
||||||
<div className="py-2">
|
|
||||||
<Link
|
|
||||||
href="/my/purchases"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-gray-800/50 transition-colors"
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
<BookOpen className="w-4 h-4" />
|
|
||||||
<span>我的购买</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/my/referral"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-gray-800/50 transition-colors"
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
<Gift className="w-4 h-4" />
|
|
||||||
<span>分销中心</span>
|
|
||||||
{user.earnings > 0 && (
|
|
||||||
<span className="ml-auto text-[#38bdac] text-sm">¥{user.earnings.toFixed(2)}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
{user.isAdmin && (
|
|
||||||
<Link
|
|
||||||
href="/admin"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-gray-300 hover:bg-gray-800/50 transition-colors"
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
<span>管理后台</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<div className="border-t border-gray-700/50 py-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
logout()
|
|
||||||
setIsMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 text-red-400 hover:bg-gray-800/50 transition-colors"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
<span>退出登录</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
676
lib/book-data.ts
676
lib/book-data.ts
@@ -1,676 +0,0 @@
|
|||||||
export interface Section {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
price: number
|
|
||||||
isFree: boolean
|
|
||||||
filePath: string
|
|
||||||
content?: string
|
|
||||||
createdAt?: string
|
|
||||||
unlockAfterDays?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Chapter {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
sections: Section[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Part {
|
|
||||||
id: string
|
|
||||||
number: string
|
|
||||||
title: string
|
|
||||||
subtitle: string
|
|
||||||
chapters: Chapter[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BASE_BOOK_PRICE = 9.9
|
|
||||||
export const PRICE_INCREMENT_PER_SECTION = 1
|
|
||||||
export const SECTION_PRICE = 1
|
|
||||||
export const AUTHOR_SHARE = 0.9
|
|
||||||
export const DISTRIBUTOR_SHARE = 0.1
|
|
||||||
|
|
||||||
export function getFullBookPrice(sectionsCount?: number): number {
|
|
||||||
return 9.9
|
|
||||||
}
|
|
||||||
|
|
||||||
export const bookData: Part[] = [
|
|
||||||
{
|
|
||||||
id: "part-1",
|
|
||||||
number: "01",
|
|
||||||
title: "真实的人",
|
|
||||||
subtitle: "人性观察与社交逻辑",
|
|
||||||
chapters: [
|
|
||||||
{
|
|
||||||
id: "chapter-1",
|
|
||||||
title: "人与人之间的底层逻辑",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "1.1",
|
|
||||||
title: "自行车荷总:一个行业做到极致是什么样",
|
|
||||||
price: 1,
|
|
||||||
isFree: true,
|
|
||||||
filePath: "book/_第一篇|真实的人/第1章|人与人之间的底层逻辑/1.1 自行车荷总:一个行业做到极致是什么样.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "1.2",
|
|
||||||
title: "老墨:资源整合高手的社交方法",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/_第一篇|真实的人/第1章|人与人之间的底层逻辑/1.2 老墨:资源整合高手的社交方法.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "1.3",
|
|
||||||
title: "笑声背后的MBTI:为什么ENTJ适合做资源,INTP适合做系统",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
filePath:
|
|
||||||
"book/_第一篇|真实的人/第1章|人与人之间的底层逻辑/1.3 笑声背后的MBTI:为什么ENTJ适合做资源,INTP适合做系统.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "1.4",
|
|
||||||
title: "人性的三角结构:情绪、价值、利益",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
filePath: "book/_第一篇|真实的人/第1章|人与人之间的底层逻辑/1.4 人性的三角结构:情绪、价值、利益.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "1.5",
|
|
||||||
title: "为什么99%的合作死在沟通差而不是能力差",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
filePath: "book/_第一篇|真实的人/第1章|人与人之间的底层逻辑/1.5 为什么99%的合作死在沟通差而不是能力差.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chapter-2",
|
|
||||||
title: "人性困境案例",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "2.1",
|
|
||||||
title: "相亲故事:你以为找的是人,实际是在找模式",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/_第一篇|真实的人/第2章|人性困境案例/2.1 相亲故事:你以为找的是人,实际是在找模式.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2.2",
|
|
||||||
title: "找工作迷茫者:为什么简历解决不了人生",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/_第一篇|真实的人/第2章|人性困境案例/2.2 找工作迷茫者:为什么简历解决不了人生.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2.3",
|
|
||||||
title: "撸运费险:小钱困住大脑的真实心理",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/_第一篇|真实的人/第2章|人性困境案例/2.3 撸运费险:小钱困住大脑的真实心理.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2.4",
|
|
||||||
title: "游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath:
|
|
||||||
"book/_第一篇|真实的人/第2章|人性困境案例/2.4 游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2.5",
|
|
||||||
title: "健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath:
|
|
||||||
"book/_第一篇|真实的人/第2章|人性困境案例/2.5 健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "part-2",
|
|
||||||
number: "02",
|
|
||||||
title: "真实的行业",
|
|
||||||
subtitle: "社会运作的底层规则",
|
|
||||||
chapters: [
|
|
||||||
{
|
|
||||||
id: "chapter-3",
|
|
||||||
title: "电商篇",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "3.1",
|
|
||||||
title: "电商财税窗口:我错过的第一桶金",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第3章|电商篇/3.1 电商财税窗口:我错过的第一桶金.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3.2",
|
|
||||||
title: "3000万流水如何跑出来(退税模式解析)",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第3章|电商篇/3.2 3000万流水如何跑出来(退税模式解析).md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3.3",
|
|
||||||
title: "供应链之王vs打工人:利润不在前端",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第3章|电商篇/3.3 供应链之王 vs 打工人:利润不在前端.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3.4",
|
|
||||||
title: "社区团购的底层逻辑",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第3章|电商篇/3.4 社区团购的底层逻辑.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3.5",
|
|
||||||
title: "跨境电商与退税套利",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第3章|电商篇/3.5 跨境电商与退税套利.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chapter-4",
|
|
||||||
title: "内容商业篇",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "4.1",
|
|
||||||
title: "旅游号:30天10万粉的真实逻辑",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第4章|内容商业篇/4.1 旅游号:30天10万粉的真实逻辑.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4.2",
|
|
||||||
title: "做号工厂:如何让一个号变成一个机器",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第4章|内容商业篇/4.2 做号工厂:如何让一个号变成一个机器.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4.3",
|
|
||||||
title: "情绪内容为什么比专业内容更赚钱",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第4章|内容商业篇/4.3 情绪内容为什么比专业内容更赚钱.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4.4",
|
|
||||||
title: "猫与宠物号:为什么宠物赛道永不过时",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第4章|内容商业篇/4.4 猫与宠物号:为什么宠物赛道永不过时.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4.5",
|
|
||||||
title: "直播间里的三种人:演员、技术工、系统流",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第4章|内容商业篇/4.5 直播间里的三种人:演员、技术工、系统流.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chapter-5",
|
|
||||||
title: "传统行业篇",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "5.1",
|
|
||||||
title: "羽毛球馆:为什么体育培训是最稳定的现金流",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第5章|传统行业篇/5.1 羽毛球馆:为什么体育培训是最稳定的现金流.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5.2",
|
|
||||||
title: "旅游供应链:资源越老越值钱",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第5章|传统行业篇/5.2 旅游供应链:资源越老越值钱.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5.3",
|
|
||||||
title: "景区联盟:门票不是目的,是流量入口",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第5章|传统行业篇/5.3 景区联盟:门票不是目的,是流量入口.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5.4",
|
|
||||||
title: "拍卖行抱朴:我人生错过的4件大钱机会(完整版)",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第5章|传统行业篇/5.4 拍卖行抱朴:我人生错过的4件大钱机会(完整版).md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5.5",
|
|
||||||
title: "飞机票供应链:为什么越便宜越亏",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第二篇|真实的行业/第5章|传统行业篇/5.5 飞机票供应链:为什么越便宜越亏.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "part-3",
|
|
||||||
number: "03",
|
|
||||||
title: "真实的错误",
|
|
||||||
subtitle: "错过机会比失败更贵",
|
|
||||||
chapters: [
|
|
||||||
{
|
|
||||||
id: "chapter-6",
|
|
||||||
title: "我人生错过的4件大钱",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "6.1",
|
|
||||||
title: "错过电商财税(2016-2017)",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第三篇|真实的错误/第6章|我人生错过的4件大钱/6.1 错过电商财税(2016-2017).md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "6.2",
|
|
||||||
title: "错过供应链(2017-2018)",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第三篇|真实的错误/第6章|我人生错过的4件大钱/6.2 错过供应链(2017-2018).md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "6.3",
|
|
||||||
title: "错过内容红利(2018-2019)",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第三篇|真实的错误/第6章|我人生错过的4件大钱/6.3 错过内容红利(2018-2019).md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "6.4",
|
|
||||||
title: "错过资源资产化(2019-2020)",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第三篇|真实的错误/第6章|我人生错过的4件大钱/6.4 错过资源资产化(2019-2020).md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chapter-7",
|
|
||||||
title: "别人犯的错误",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "7.1",
|
|
||||||
title: "投资房年轻人的迷茫:资金vs能力",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第三篇|真实的错误/第7章|别人犯的错误/7.1 投资房年轻人的迷茫:资金 vs 能力.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "7.2",
|
|
||||||
title: "信息差骗局:永远有人靠卖学习赚钱",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第三篇|真实的错误/第7章|别人犯的错误/7.2 信息差骗局:永远有人靠卖学习赚钱.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "7.3",
|
|
||||||
title: "在Soul找恋爱但想赚钱的人",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第三篇|真实的错误/第7章|别人犯的错误/7.3 在Soul找恋爱但想赚钱的人.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "7.4",
|
|
||||||
title: "创业者的三种死法:冲动、轻信、没结构",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第三篇|真实的错误/第7章|别人犯的错误/7.4 创业者的三种死法:冲动、轻信、没结构.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "7.5",
|
|
||||||
title: "人情生意的终点:关系越多亏得越多",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第三篇|真实的错误/第7章|别人犯的错误/7.5 人情生意的终点:关系越多亏得越多.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "part-4",
|
|
||||||
number: "04",
|
|
||||||
title: "真实的赚钱",
|
|
||||||
subtitle: "所有行业的杠杆结构",
|
|
||||||
chapters: [
|
|
||||||
{
|
|
||||||
id: "chapter-8",
|
|
||||||
title: "底层结构",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "8.1",
|
|
||||||
title: "流量杠杆:抖音、Soul、飞书",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第8章|底层结构/8.1 流量杠杆:抖音、Soul、飞书.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "8.2",
|
|
||||||
title: "价格杠杆:供应链与信息差",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第8章|底层结构/8.2 价格杠杆:供应链与信息差.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "8.3",
|
|
||||||
title: "时间杠杆:自动化+AI",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第8章|底层结构/8.3 时间杠杆:自动化 + AI.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "8.4",
|
|
||||||
title: "情绪杠杆:咨询、婚恋、生意场",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第8章|底层结构/8.4 情绪杠杆:咨询、婚恋、生意场.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "8.5",
|
|
||||||
title: "社交杠杆:认识谁比你会什么更重要",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第8章|底层结构/8.5 社交杠杆:认识谁比你会什么更重要.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "8.6",
|
|
||||||
title: "云阿米巴:分不属于自己的钱",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第8章|底层结构/8.6 云阿米巴:分不属于自己的钱.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chapter-9",
|
|
||||||
title: "我在Soul上亲访的赚钱案例",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "9.1",
|
|
||||||
title: "游戏账号私域:账号即资产",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.1 游戏账号私域:账号即资产.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "9.2",
|
|
||||||
title: "健康包模式:高复购、高毛利",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.2 健康包模式:高复购、高毛利.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "9.3",
|
|
||||||
title: "药物私域:长期关系赛道",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.3 药物私域:长期关系赛道.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "9.4",
|
|
||||||
title: "残疾机构合作:退税×AI×人力成本",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath:
|
|
||||||
"book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.4 残疾机构合作:退税 × AI × 人力成本.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "9.5",
|
|
||||||
title: "私域银行:粉丝即小股东",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.5 私域银行:粉丝即小股东.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "9.6",
|
|
||||||
title: "Soul派对房:陌生人成交的最快场景",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.6 Soul派对房:陌生人成交的最快场景.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "9.7",
|
|
||||||
title: "飞书中台:从聊天到成交的流程化体系",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath:
|
|
||||||
"book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.7 飞书中台:从聊天到成交的流程化体系.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "part-5",
|
|
||||||
number: "05",
|
|
||||||
title: "真实的未来",
|
|
||||||
subtitle: "人与系统的关系",
|
|
||||||
chapters: [
|
|
||||||
{
|
|
||||||
id: "chapter-10",
|
|
||||||
title: "未来职业的变化趋势",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "10.1",
|
|
||||||
title: "AI代聊与岗位替换",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第五篇|真实的社会/第10章|未来职业的变化趋势/10.1 AI代聊与岗位替换.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "10.2",
|
|
||||||
title: "系统化工作vs杂乱工作",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第五篇|真实的社会/第10章|未来职业的变化趋势/10.2 系统化工作 vs 杂乱工作.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "10.3",
|
|
||||||
title: "为什么链接能力会成为第一价值",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第五篇|真实的社会/第10章|未来职业的变化趋势/10.3 为什么链接能力会成为第一价值.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "10.4",
|
|
||||||
title: "新型公司:Soul-飞书-线下的三位一体",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第五篇|真实的社会/第10章|未来职业的变化趋势/10.4 新型公司:Soul-飞书-线下的三位一体.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "chapter-11",
|
|
||||||
title: "中国社会商业生态的未来",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "11.1",
|
|
||||||
title: "城市之间的模式差",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第五篇|真实的社会/第11章|中国社会商业生态的未来/11.1 城市之间的模式差.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "11.2",
|
|
||||||
title: "厦门样本:低成本高效率经济",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第五篇|真实的社会/第11章|中国社会商业生态的未来/11.2 厦门样本:低成本高效率经济.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "11.3",
|
|
||||||
title: "流量红利的终局",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第五篇|真实的社会/第11章|中国社会商业生态的未来/11.3 流量红利的终局.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "11.4",
|
|
||||||
title: "大模型+供应链的组合拳",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第五篇|真实的社会/第11章|中国社会商业生态的未来/11.4 大模型 + 供应链的组合拳.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "11.5",
|
|
||||||
title: "社会分层的最终逻辑",
|
|
||||||
price: 1,
|
|
||||||
isFree: false,
|
|
||||||
unlockAfterDays: 3,
|
|
||||||
filePath: "book/第五篇|真实的社会/第11章|中国社会商业生态的未来/11.5 社会分层的最终逻辑.md",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const specialSections = {
|
|
||||||
preface: {
|
|
||||||
id: "preface",
|
|
||||||
title: "序言|为什么我每天早上6点在Soul开播?",
|
|
||||||
price: 0,
|
|
||||||
isFree: true,
|
|
||||||
filePath: "book/序言|为什么我每天早上6点在Soul开播?.md",
|
|
||||||
},
|
|
||||||
epilogue: {
|
|
||||||
id: "epilogue",
|
|
||||||
title: "尾声|终极答案:努力不是关键,选择才是",
|
|
||||||
price: 0,
|
|
||||||
isFree: true,
|
|
||||||
filePath: "book/尾声|终极答案:努力不是关键,选择才是.md",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FULL_BOOK_PRICE = getFullBookPrice()
|
|
||||||
|
|
||||||
export function getAllSections(): Section[] {
|
|
||||||
const sections: Section[] = []
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const customSections = JSON.parse(localStorage.getItem("custom_sections") || "[]")
|
|
||||||
sections.push(...customSections)
|
|
||||||
}
|
|
||||||
bookData.forEach((part) => {
|
|
||||||
part.chapters.forEach((chapter) => {
|
|
||||||
sections.push(...chapter.sections)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return sections
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSectionById(id: string): Section | undefined {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const customSections = JSON.parse(localStorage.getItem("custom_sections") || "[]") as Section[]
|
|
||||||
const customSection = customSections.find((s) => s.id === id)
|
|
||||||
if (customSection) return customSection
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const part of bookData) {
|
|
||||||
for (const chapter of part.chapters) {
|
|
||||||
const section = chapter.sections.find((s) => s.id === id)
|
|
||||||
if (section) return section
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getChapterBySection(sectionId: string): { part: Part; chapter: Chapter } | undefined {
|
|
||||||
for (const part of bookData) {
|
|
||||||
for (const chapter of part.chapters) {
|
|
||||||
if (chapter.sections.some((s) => s.id === sectionId)) {
|
|
||||||
return { part, chapter }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSectionUnlocked(section: Section): boolean {
|
|
||||||
if (section.isFree) return true
|
|
||||||
if (!section.unlockAfterDays || !section.createdAt) return false
|
|
||||||
|
|
||||||
const createdDate = new Date(section.createdAt)
|
|
||||||
const unlockDate = new Date(createdDate.getTime() + section.unlockAfterDays * 24 * 60 * 60 * 1000)
|
|
||||||
return new Date() >= unlockDate
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addCustomSection(section: Omit<Section, "createdAt">): Section {
|
|
||||||
const newSection: Section = {
|
|
||||||
...section,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const customSections = JSON.parse(localStorage.getItem("custom_sections") || "[]") as Section[]
|
|
||||||
customSections.push(newSection)
|
|
||||||
localStorage.setItem("custom_sections", JSON.stringify(customSections))
|
|
||||||
}
|
|
||||||
|
|
||||||
return newSection
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import type { Part, Chapter, Section } from "./book-data"
|
|
||||||
|
|
||||||
const BOOK_DIR = path.join(process.cwd(), "book")
|
|
||||||
|
|
||||||
const CHINESE_NUM_MAP: Record<string, string> = {
|
|
||||||
一: "01",
|
|
||||||
二: "02",
|
|
||||||
三: "03",
|
|
||||||
四: "04",
|
|
||||||
五: "05",
|
|
||||||
六: "06",
|
|
||||||
七: "07",
|
|
||||||
八: "08",
|
|
||||||
九: "09",
|
|
||||||
十: "10",
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUBTITLES: Record<string, string> = {
|
|
||||||
真实的人: "人性观察与社交逻辑",
|
|
||||||
真实的行业: "社会运作的底层规则",
|
|
||||||
真实的错误: "错过机会比失败更贵",
|
|
||||||
真实的赚钱: "所有行业的杠杆结构",
|
|
||||||
真实的社会: "人与系统的关系",
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePartFolderName(folderName: string): { number: string; title: string } | null {
|
|
||||||
const match = folderName.match(/_第([一二三四五六七八九十]+)篇|(.+)/)
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
number: CHINESE_NUM_MAP[match[1]] || match[1],
|
|
||||||
title: match[2],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChapterFolderName(folderName: string): { id: string; title: string } | null {
|
|
||||||
const match = folderName.match(/第(\d+)章|(.+)/)
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
id: match[1],
|
|
||||||
title: match[2],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSectionFileName(fileName: string): { id: string; title: string } | null {
|
|
||||||
if (!fileName.endsWith(".md")) return null
|
|
||||||
const name = fileName.replace(".md", "")
|
|
||||||
const match = name.match(/^([\d.]+)\s+(.+)/)
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
id: match[1],
|
|
||||||
title: match[2],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: fileName,
|
|
||||||
title: name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBookStructure(): Part[] {
|
|
||||||
if (!fs.existsSync(BOOK_DIR)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const partFolders = fs.readdirSync(BOOK_DIR).filter((f) => {
|
|
||||||
const fullPath = path.join(BOOK_DIR, f)
|
|
||||||
return fs.statSync(fullPath).isDirectory() && f.startsWith("_")
|
|
||||||
})
|
|
||||||
|
|
||||||
const parts: Part[] = partFolders
|
|
||||||
.map((folderName) => {
|
|
||||||
const parsed = parsePartFolderName(folderName)
|
|
||||||
if (!parsed) return null
|
|
||||||
|
|
||||||
const partPath = path.join(BOOK_DIR, folderName)
|
|
||||||
const chapterFolders = fs.readdirSync(partPath).filter((f) => {
|
|
||||||
const fullPath = path.join(partPath, f)
|
|
||||||
return fs.statSync(fullPath).isDirectory() && f.startsWith("第")
|
|
||||||
})
|
|
||||||
|
|
||||||
const chapters: Chapter[] = chapterFolders
|
|
||||||
.map((chapterFolderName) => {
|
|
||||||
const parsedChapter = parseChapterFolderName(chapterFolderName)
|
|
||||||
if (!parsedChapter) return null
|
|
||||||
|
|
||||||
const chapterPath = path.join(partPath, chapterFolderName)
|
|
||||||
const sectionFiles = fs.readdirSync(chapterPath).filter((f) => f.endsWith(".md"))
|
|
||||||
|
|
||||||
const sections: Section[] = sectionFiles
|
|
||||||
.map((fileName) => {
|
|
||||||
const parsedSection = parseSectionFileName(fileName)
|
|
||||||
if (!parsedSection) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: parsedSection.id,
|
|
||||||
title: parsedSection.title,
|
|
||||||
price: 1,
|
|
||||||
isFree: parsedSection.id.endsWith(".1"),
|
|
||||||
filePath: path.relative(process.cwd(), path.join(chapterPath, fileName)),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((s): s is Section => s !== null)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const partsA = a.id.split(".").map(Number)
|
|
||||||
const partsB = b.id.split(".").map(Number)
|
|
||||||
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
||||||
const valA = partsA[i] || 0
|
|
||||||
const valB = partsB[i] || 0
|
|
||||||
if (valA !== valB) return valA - valB
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: parsedChapter.id,
|
|
||||||
title: parsedChapter.title,
|
|
||||||
sections,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((c): c is Chapter => c !== null)
|
|
||||||
.sort((a, b) => Number(a.id) - Number(b.id))
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: parsed.number,
|
|
||||||
number: parsed.number,
|
|
||||||
title: parsed.title,
|
|
||||||
subtitle: SUBTITLES[parsed.title] || "",
|
|
||||||
chapters,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((p): p is Part => p !== null)
|
|
||||||
.sort((a, b) => Number(a.number) - Number(b.number))
|
|
||||||
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSectionBySlug(slug: string): Section | null {
|
|
||||||
const parts = getBookStructure()
|
|
||||||
for (const part of parts) {
|
|
||||||
for (const chapter of part.chapters) {
|
|
||||||
for (const section of chapter.sections) {
|
|
||||||
if (section.id === slug) {
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getChapterBySectionSlug(slug: string): { part: Part; chapter: Chapter } | null {
|
|
||||||
const parts = getBookStructure()
|
|
||||||
for (const part of parts) {
|
|
||||||
for (const chapter of part.chapters) {
|
|
||||||
for (const section of chapter.sections) {
|
|
||||||
if (section.id === slug) {
|
|
||||||
return { part, chapter }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { bookData, specialSections } from "@/lib/book-data"
|
|
||||||
|
|
||||||
export type DocumentationPage = {
|
|
||||||
path: string
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
caption?: string
|
|
||||||
group: string
|
|
||||||
waitForSelector?: string
|
|
||||||
order?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickRepresentativeReadIds(): { id: string; title: string; group: string }[] {
|
|
||||||
const picks: { id: string; title: string; group: string }[] = []
|
|
||||||
|
|
||||||
picks.push({ id: specialSections.preface.id, title: specialSections.preface.title, group: "阅读页面" })
|
|
||||||
|
|
||||||
for (const part of bookData) {
|
|
||||||
const firstChapter = part.chapters[0]
|
|
||||||
const firstSection = firstChapter?.sections?.[0]
|
|
||||||
if (firstSection) {
|
|
||||||
picks.push({
|
|
||||||
id: firstSection.id,
|
|
||||||
title: `${part.number} ${part.title}|${firstSection.title}`,
|
|
||||||
group: "阅读页面",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraReadIds = ["9.11", "9.10", "9.9"]
|
|
||||||
for (const targetId of extraReadIds) {
|
|
||||||
const found = bookData
|
|
||||||
.flatMap((p) => p.chapters)
|
|
||||||
.flatMap((c) => c.sections)
|
|
||||||
.find((s) => s.id === targetId)
|
|
||||||
if (found) {
|
|
||||||
picks.push({ id: found.id, title: found.title, group: "阅读页面" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>()
|
|
||||||
return picks.filter((p) => {
|
|
||||||
if (seen.has(p.id)) return false
|
|
||||||
seen.add(p.id)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDocumentationCatalog(): DocumentationPage[] {
|
|
||||||
const pages: DocumentationPage[] = [
|
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
title: "首页",
|
|
||||||
subtitle: "应用主入口",
|
|
||||||
caption:
|
|
||||||
"首页是用户进入应用的第一个页面,展示书籍封面、简介、目录预览和购买入口。用户可以快速了解内容概要并进行购买决策。",
|
|
||||||
group: "核心页面",
|
|
||||||
order: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/chapters",
|
|
||||||
title: "目录页",
|
|
||||||
subtitle: "章节浏览与导航",
|
|
||||||
caption:
|
|
||||||
"目录页展示全书的完整章节结构,用户可以浏览各篇、各章内容,查看已解锁和待解锁章节,并快速跳转到阅读页面。",
|
|
||||||
group: "核心页面",
|
|
||||||
order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/about",
|
|
||||||
title: "关于页面",
|
|
||||||
subtitle: "作者与产品介绍",
|
|
||||||
caption: "关于页面展示作者信息、产品理念、运营数据等,帮助用户建立对内容的信任和理解。",
|
|
||||||
group: "核心页面",
|
|
||||||
order: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/my",
|
|
||||||
title: "个人中心",
|
|
||||||
subtitle: "用户账户入口",
|
|
||||||
caption: "个人中心聚合用户的账户信息、购买记录、分销收益等功能入口,是用户管理个人信息的核心页面。",
|
|
||||||
group: "用户中心",
|
|
||||||
order: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/my/purchases",
|
|
||||||
title: "我的购买",
|
|
||||||
subtitle: "已购内容管理",
|
|
||||||
caption: "展示用户已购买的所有章节,包括购买时间、解锁进度,用户可快速跳转到已购内容继续阅读。",
|
|
||||||
group: "用户中心",
|
|
||||||
order: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/my/settings",
|
|
||||||
title: "账户设置",
|
|
||||||
subtitle: "个人信息配置",
|
|
||||||
caption: "用户可在此页面管理个人基础信息、通知偏好、隐私设置等账户相关配置。",
|
|
||||||
group: "用户中心",
|
|
||||||
order: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/my/referral",
|
|
||||||
title: "分销中心",
|
|
||||||
subtitle: "邀请与收益管理",
|
|
||||||
caption: "分销中心展示用户的专属邀请链接、邀请人数统计、收益明细,支持一键分享到朋友圈或Soul派对。",
|
|
||||||
group: "用户中心",
|
|
||||||
order: 7,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/admin/login",
|
|
||||||
title: "后台登录",
|
|
||||||
subtitle: "管理员入口",
|
|
||||||
caption: "管理后台的登录页面,管理员通过账号密码验证后进入管理系统。",
|
|
||||||
group: "管理后台",
|
|
||||||
order: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/admin",
|
|
||||||
title: "后台管理",
|
|
||||||
subtitle: "系统配置中心",
|
|
||||||
caption: "管理后台的核心页面,包含数据概览、内容管理、用户管理、支付配置、二维码管理、系统设置等功能模块。",
|
|
||||||
group: "管理后台",
|
|
||||||
order: 9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/docs",
|
|
||||||
title: "开发文档",
|
|
||||||
subtitle: "技术与配置说明",
|
|
||||||
caption: "面向开发者和运营人员的技术文档,包含支付接口配置说明、分销规则详解、提现流程等内容。",
|
|
||||||
group: "运营支持",
|
|
||||||
order: 10,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const readPicks = pickRepresentativeReadIds()
|
|
||||||
for (let i = 0; i < readPicks.length; i++) {
|
|
||||||
const pick = readPicks[i]
|
|
||||||
pages.push({
|
|
||||||
path: `/read/${encodeURIComponent(pick.id)}`,
|
|
||||||
title: pick.title,
|
|
||||||
subtitle: "章节阅读",
|
|
||||||
caption: "阅读页面展示章节的完整内容,未购买用户可预览部分内容,付费墙引导购买解锁全文。",
|
|
||||||
group: pick.group,
|
|
||||||
waitForSelector: "main",
|
|
||||||
order: 100 + i,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by order
|
|
||||||
return pages.sort((a, b) => (a.order || 999) - (b.order || 999))
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
import {
|
|
||||||
Document,
|
|
||||||
HeadingLevel,
|
|
||||||
ImageRun,
|
|
||||||
Packer,
|
|
||||||
Paragraph,
|
|
||||||
TableOfContents,
|
|
||||||
TextRun,
|
|
||||||
AlignmentType,
|
|
||||||
PageBreak,
|
|
||||||
BorderStyle,
|
|
||||||
} from "docx"
|
|
||||||
import type { DocumentationPage } from "@/lib/documentation/catalog"
|
|
||||||
|
|
||||||
export type DocumentationRenderItem = {
|
|
||||||
page: DocumentationPage
|
|
||||||
screenshotPng?: Buffer
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupBy<T>(items: T[], getKey: (item: T) => string): Record<string, T[]> {
|
|
||||||
const map: Record<string, T[]> = {}
|
|
||||||
for (const item of items) {
|
|
||||||
const key = getKey(item)
|
|
||||||
if (!map[key]) map[key] = []
|
|
||||||
map[key].push(item)
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renderDocumentationDocx(items: DocumentationRenderItem[]) {
|
|
||||||
const now = new Date()
|
|
||||||
const title = "Soul派对 - 应用功能文档"
|
|
||||||
const subtitle = `生成时间:${now.toLocaleString("zh-CN", { hour12: false })}`
|
|
||||||
const version = `文档版本:v1.0`
|
|
||||||
|
|
||||||
const children: Paragraph[] = []
|
|
||||||
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: title,
|
|
||||||
heading: HeadingLevel.TITLE,
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new TextRun({ text: subtitle, size: 24 })],
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new TextRun({ text: version, size: 20, color: "666666" })],
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: "文档概述",
|
|
||||||
heading: HeadingLevel.HEADING_1,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new TextRun({
|
|
||||||
text: "本文档自动生成,包含应用程序所有核心页面的功能说明与界面截图。文档按功能模块分组,便于快速查阅和理解应用结构。",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new TextRun({
|
|
||||||
text: `共包含 ${items.length} 个页面,${Object.keys(groupBy(items, (i) => i.page.group)).length} 个功能模块。`,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: "目录",
|
|
||||||
heading: HeadingLevel.HEADING_1,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new TableOfContents("目录", {
|
|
||||||
hyperlink: true,
|
|
||||||
headingStyleRange: "1-3",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new PageBreak()],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const grouped = groupBy(items, (i) => i.page.group)
|
|
||||||
const groupNames = Object.keys(grouped)
|
|
||||||
|
|
||||||
let pageNumber = 1
|
|
||||||
for (const groupName of groupNames) {
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: groupName,
|
|
||||||
heading: HeadingLevel.HEADING_1,
|
|
||||||
border: {
|
|
||||||
bottom: { color: "2DD4BF", size: 6, style: BorderStyle.SINGLE },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
|
|
||||||
for (const item of grouped[groupName]) {
|
|
||||||
const { page } = item
|
|
||||||
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: `${pageNumber}. ${page.title}`,
|
|
||||||
heading: HeadingLevel.HEADING_2,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (page.subtitle) {
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new TextRun({ text: page.subtitle, italics: true, color: "666666" })],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new TextRun({ text: "页面路径:", bold: true }),
|
|
||||||
new TextRun({ text: page.path, color: "2563EB" }),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (page.caption) {
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new TextRun({ text: "功能说明:", bold: true })],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new TextRun({ text: page.caption })],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
|
|
||||||
if (item.error) {
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new TextRun({ text: "截图状态:", bold: true }),
|
|
||||||
new TextRun({ text: `失败 - ${item.error}`, color: "DC2626" }),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} else if (item.screenshotPng) {
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new TextRun({ text: "界面截图:", bold: true })],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new ImageRun({
|
|
||||||
data: item.screenshotPng,
|
|
||||||
transformation: { width: 320, height: 693 },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new TextRun({ text: `图${pageNumber}: ${page.title}界面`, size: 20, color: "666666" })],
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
pageNumber++
|
|
||||||
}
|
|
||||||
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new PageBreak()],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
text: "附录",
|
|
||||||
heading: HeadingLevel.HEADING_1,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new TextRun({ text: "技术说明", bold: true })],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new TextRun({
|
|
||||||
text: "• 截图尺寸:430×932像素 (iPhone 14 Pro Max)",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new TextRun({
|
|
||||||
text: "• 截图方式:Playwright自动化浏览器截图",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [
|
|
||||||
new TextRun({
|
|
||||||
text: "• 文档格式:Microsoft Word (.docx)",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
children.push(new Paragraph({ text: "" }))
|
|
||||||
children.push(
|
|
||||||
new Paragraph({
|
|
||||||
children: [new TextRun({ text: "本文档由系统自动生成,如有问题请联系技术支持。", color: "666666", size: 20 })],
|
|
||||||
alignment: AlignmentType.CENTER,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const doc = new Document({
|
|
||||||
title: "Soul派对 - 应用功能文档",
|
|
||||||
description: "自动生成的应用功能文档",
|
|
||||||
creator: "Soul派对文档生成器",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
properties: {},
|
|
||||||
children,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
return await Packer.toBuffer(doc)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import type { DocumentationPage } from "@/lib/documentation/catalog"
|
|
||||||
|
|
||||||
export type ScreenshotResult = {
|
|
||||||
page: DocumentationPage
|
|
||||||
screenshotPng?: Buffer
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CaptureOptions = {
|
|
||||||
baseUrl: string
|
|
||||||
timeoutMs: number
|
|
||||||
viewport: { width: number; height: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureScreenshots(
|
|
||||||
pages: DocumentationPage[],
|
|
||||||
options: CaptureOptions,
|
|
||||||
): Promise<ScreenshotResult[]> {
|
|
||||||
const { chromium } = await import("playwright")
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results: ScreenshotResult[] = []
|
|
||||||
|
|
||||||
for (const pageInfo of pages) {
|
|
||||||
const page = await browser.newPage({ viewport: options.viewport })
|
|
||||||
try {
|
|
||||||
const captureUrl = new URL("/documentation/capture", options.baseUrl)
|
|
||||||
captureUrl.searchParams.set("path", pageInfo.path)
|
|
||||||
|
|
||||||
console.log(`[v0] Capturing: ${pageInfo.path}`)
|
|
||||||
|
|
||||||
await page.goto(captureUrl.toString(), {
|
|
||||||
waitUntil: "networkidle",
|
|
||||||
timeout: options.timeoutMs,
|
|
||||||
})
|
|
||||||
|
|
||||||
const iframeHandle = await page.waitForSelector('iframe[data-doc-iframe="true"]', {
|
|
||||||
timeout: options.timeoutMs,
|
|
||||||
})
|
|
||||||
|
|
||||||
const frame = await iframeHandle.contentFrame()
|
|
||||||
if (!frame) {
|
|
||||||
throw new Error("无法获取iframe内容")
|
|
||||||
}
|
|
||||||
|
|
||||||
await frame.waitForLoadState("domcontentloaded", { timeout: options.timeoutMs })
|
|
||||||
|
|
||||||
// Allow network to settle
|
|
||||||
await frame.waitForLoadState("networkidle", { timeout: options.timeoutMs }).catch(() => {
|
|
||||||
console.log(`[v0] Network idle timeout for ${pageInfo.path}, continuing...`)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (pageInfo.waitForSelector) {
|
|
||||||
await frame
|
|
||||||
.waitForSelector(pageInfo.waitForSelector, {
|
|
||||||
timeout: options.timeoutMs,
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
console.log(`[v0] Selector timeout for ${pageInfo.path}, continuing...`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.waitForTimeout(500)
|
|
||||||
|
|
||||||
const screenshot = await iframeHandle.screenshot({
|
|
||||||
type: "png",
|
|
||||||
animations: "disabled",
|
|
||||||
})
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
page: pageInfo,
|
|
||||||
screenshotPng: Buffer.from(screenshot),
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`[v0] Success: ${pageInfo.path}`)
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
console.log(`[v0] Error capturing ${pageInfo.path}: ${message}`)
|
|
||||||
results.push({ page: pageInfo, error: message })
|
|
||||||
} finally {
|
|
||||||
await page.close().catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
} finally {
|
|
||||||
await browser.close().catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import matter from 'gray-matter'
|
|
||||||
|
|
||||||
export interface MarkdownContent {
|
|
||||||
frontmatter: {
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMarkdownContent(filePath: string): MarkdownContent {
|
|
||||||
try {
|
|
||||||
const fullPath = path.join(process.cwd(), filePath)
|
|
||||||
|
|
||||||
// Ensure file exists
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
console.warn(`File not found: ${filePath}`)
|
|
||||||
return {
|
|
||||||
frontmatter: {},
|
|
||||||
content: 'Content not found.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileContents = fs.readFileSync(fullPath, 'utf8')
|
|
||||||
const { data, content } = matter(fileContents)
|
|
||||||
|
|
||||||
return {
|
|
||||||
frontmatter: data,
|
|
||||||
content,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error reading markdown file: ${filePath}`, error)
|
|
||||||
return {
|
|
||||||
frontmatter: {},
|
|
||||||
content: 'Error loading content.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
export type CampaignType = 'popup' | 'banner' | 'modal' | 'toast';
|
|
||||||
|
|
||||||
export interface Campaign {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: CampaignType;
|
|
||||||
isActive: boolean;
|
|
||||||
rules: CampaignRule[];
|
|
||||||
content: CampaignContent;
|
|
||||||
startDate?: Date;
|
|
||||||
endDate?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CampaignRule {
|
|
||||||
type: 'time_on_page' | 'scroll_depth' | 'exit_intent' | 'user_segment';
|
|
||||||
value: number | string; // e.g., 30 (seconds), 50 (percent)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CampaignContent {
|
|
||||||
title?: string;
|
|
||||||
body?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
ctaText?: string;
|
|
||||||
ctaUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarketingService {
|
|
||||||
getActiveCampaigns(context: UserContext): Promise<Campaign[]>;
|
|
||||||
trackImpression(campaignId: string): void;
|
|
||||||
trackClick(campaignId: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserContext {
|
|
||||||
userId?: string;
|
|
||||||
pageUrl: string;
|
|
||||||
device: 'mobile' | 'desktop';
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export type PaymentStatus = 'pending' | 'success' | 'failed' | 'refunded';
|
|
||||||
|
|
||||||
export interface Order {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
status: PaymentStatus;
|
|
||||||
items: OrderItem[];
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderItem {
|
|
||||||
id: string;
|
|
||||||
productId: string;
|
|
||||||
productType: 'section' | 'full_book' | 'membership';
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentProvider {
|
|
||||||
name: string;
|
|
||||||
createOrder(order: Order): Promise<{ payUrl: string; orderId: string }>;
|
|
||||||
checkStatus(orderId: string): Promise<PaymentStatus>;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
export interface ReferralCode {
|
|
||||||
code: string;
|
|
||||||
ownerId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
campaignId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReferralStats {
|
|
||||||
userId: string;
|
|
||||||
totalClicks: number;
|
|
||||||
totalConversions: number;
|
|
||||||
totalEarnings: number;
|
|
||||||
pendingEarnings: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReferralRecord {
|
|
||||||
id: string;
|
|
||||||
referrerId: string;
|
|
||||||
refereeId: string; // The new user
|
|
||||||
action: 'register' | 'purchase';
|
|
||||||
amount?: number; // Purchase amount if applicable
|
|
||||||
commission: number;
|
|
||||||
status: 'pending' | 'paid' | 'cancelled';
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReferralService {
|
|
||||||
generateCode(userId: string): Promise<string>;
|
|
||||||
trackVisit(code: string, visitorInfo: any): Promise<void>;
|
|
||||||
recordConversion(code: string, action: 'register' | 'purchase', amount?: number): Promise<ReferralRecord>;
|
|
||||||
getStats(userId: string): Promise<ReferralStats>;
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
export interface PaymentOrder {
|
|
||||||
orderId: string
|
|
||||||
userId: string
|
|
||||||
type: "section" | "fullbook"
|
|
||||||
sectionId?: string
|
|
||||||
sectionTitle?: string
|
|
||||||
amount: number
|
|
||||||
paymentMethod: "wechat" | "alipay" | "usdt" | "paypal"
|
|
||||||
referralCode?: string
|
|
||||||
status: "pending" | "completed" | "failed" | "refunded"
|
|
||||||
createdAt: string
|
|
||||||
expireAt: string
|
|
||||||
transactionId?: string
|
|
||||||
completedAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PaymentService {
|
|
||||||
/**
|
|
||||||
* Create a new payment order
|
|
||||||
*/
|
|
||||||
static async createOrder(params: {
|
|
||||||
userId: string
|
|
||||||
type: "section" | "fullbook"
|
|
||||||
sectionId?: string
|
|
||||||
sectionTitle?: string
|
|
||||||
amount: number
|
|
||||||
paymentMethod: string
|
|
||||||
referralCode?: string
|
|
||||||
}): Promise<PaymentOrder> {
|
|
||||||
const response = await fetch("/api/payment/create-order", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(params),
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.code !== 0) {
|
|
||||||
throw new Error(result.message || "创建订单失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify payment completion
|
|
||||||
*/
|
|
||||||
static async verifyPayment(orderId: string, transactionId?: string): Promise<boolean> {
|
|
||||||
const response = await fetch("/api/payment/verify", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ orderId, transactionId }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
return result.code === 0 && result.data.status === "completed"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user orders
|
|
||||||
*/
|
|
||||||
static async getUserOrders(userId: string): Promise<PaymentOrder[]> {
|
|
||||||
const response = await fetch(`/api/orders?userId=${userId}`)
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.code !== 0) {
|
|
||||||
throw new Error(result.message || "获取订单失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get payment gateway config
|
|
||||||
*/
|
|
||||||
static getPaymentConfig(method: "wechat" | "alipay" | "usdt" | "paypal") {
|
|
||||||
// In production, fetch from settings API
|
|
||||||
// For now, use localStorage
|
|
||||||
const settings = JSON.parse(localStorage.getItem("settings") || "{}")
|
|
||||||
return settings.paymentMethods?.[method] || {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate payment QR code URL
|
|
||||||
*/
|
|
||||||
static getPaymentQRCode(method: "wechat" | "alipay", amount: number, orderId: string): string {
|
|
||||||
const config = this.getPaymentConfig(method)
|
|
||||||
|
|
||||||
// If it's a redirect URL, return it directly
|
|
||||||
if (
|
|
||||||
config.qrCode?.startsWith("http") ||
|
|
||||||
config.qrCode?.startsWith("weixin://") ||
|
|
||||||
config.qrCode?.startsWith("alipays://")
|
|
||||||
) {
|
|
||||||
return config.qrCode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise return the QR code image
|
|
||||||
return config.qrCode || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open payment app (Wechat/Alipay)
|
|
||||||
*/
|
|
||||||
static openPaymentApp(method: "wechat" | "alipay", orderId: string): boolean {
|
|
||||||
const config = this.getPaymentConfig(method)
|
|
||||||
const redirectUrl = config.qrCode
|
|
||||||
|
|
||||||
if (!redirectUrl) {
|
|
||||||
console.error("[v0] No payment URL configured for", method)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open URL in new window/tab
|
|
||||||
window.open(redirectUrl, "_blank")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
export interface Order {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
amount: number
|
|
||||||
currency: string
|
|
||||||
status: "pending" | "paid" | "failed" | "refunded"
|
|
||||||
items: { type: "book" | "section"; id: string; title: string; price: number }[]
|
|
||||||
gateway?: string
|
|
||||||
transactionId?: string
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentConfig {
|
|
||||||
wechat: {
|
|
||||||
enabled: boolean
|
|
||||||
qrcode: string
|
|
||||||
appId?: string
|
|
||||||
}
|
|
||||||
alipay: {
|
|
||||||
enabled: boolean
|
|
||||||
qrcode: string
|
|
||||||
appId?: string
|
|
||||||
}
|
|
||||||
usdt: {
|
|
||||||
enabled: boolean
|
|
||||||
walletAddress: string
|
|
||||||
network: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ORDERS_KEY = "soul_orders"
|
|
||||||
const PAYMENT_CONFIG_KEY = "soul_payment_config"
|
|
||||||
|
|
||||||
// 订单管理
|
|
||||||
export function getOrders(): Order[] {
|
|
||||||
if (typeof window === "undefined") return []
|
|
||||||
const data = localStorage.getItem(ORDERS_KEY)
|
|
||||||
return data ? JSON.parse(data) : []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOrderById(id: string): Order | undefined {
|
|
||||||
return getOrders().find((o) => o.id === id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOrdersByUser(userId: string): Order[] {
|
|
||||||
return getOrders().filter((o) => o.userId === userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createOrder(order: Omit<Order, "id" | "createdAt" | "updatedAt">): Order {
|
|
||||||
const orders = getOrders()
|
|
||||||
const newOrder: Order = {
|
|
||||||
...order,
|
|
||||||
id: `order_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
orders.push(newOrder)
|
|
||||||
localStorage.setItem(ORDERS_KEY, JSON.stringify(orders))
|
|
||||||
return newOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateOrder(id: string, updates: Partial<Order>): Order | null {
|
|
||||||
const orders = getOrders()
|
|
||||||
const index = orders.findIndex((o) => o.id === id)
|
|
||||||
if (index === -1) return null
|
|
||||||
|
|
||||||
orders[index] = {
|
|
||||||
...orders[index],
|
|
||||||
...updates,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
localStorage.setItem(ORDERS_KEY, JSON.stringify(orders))
|
|
||||||
return orders[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 支付配置管理
|
|
||||||
export function getPaymentConfig(): PaymentConfig {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return {
|
|
||||||
wechat: { enabled: false, qrcode: "" },
|
|
||||||
alipay: { enabled: false, qrcode: "" },
|
|
||||||
usdt: { enabled: false, walletAddress: "", network: "TRC20" },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const data = localStorage.getItem(PAYMENT_CONFIG_KEY)
|
|
||||||
return data
|
|
||||||
? JSON.parse(data)
|
|
||||||
: {
|
|
||||||
wechat: { enabled: true, qrcode: "" },
|
|
||||||
alipay: { enabled: true, qrcode: "" },
|
|
||||||
usdt: { enabled: false, walletAddress: "", network: "TRC20" },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updatePaymentConfig(config: Partial<PaymentConfig>): PaymentConfig {
|
|
||||||
const current = getPaymentConfig()
|
|
||||||
const updated = { ...current, ...config }
|
|
||||||
localStorage.setItem(PAYMENT_CONFIG_KEY, JSON.stringify(updated))
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟支付流程(实际生产环境需要对接真实支付网关)
|
|
||||||
export async function simulatePayment(orderId: string, gateway: string): Promise<boolean> {
|
|
||||||
// 模拟支付延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
||||||
|
|
||||||
const order = updateOrder(orderId, {
|
|
||||||
status: "paid",
|
|
||||||
gateway,
|
|
||||||
transactionId: `txn_${Date.now()}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
return !!order
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import crypto from "crypto"
|
|
||||||
|
|
||||||
export interface AlipayConfig {
|
|
||||||
appId: string
|
|
||||||
partnerId: string
|
|
||||||
key: string
|
|
||||||
returnUrl: string
|
|
||||||
notifyUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AlipayService {
|
|
||||||
constructor(private config: AlipayConfig) {}
|
|
||||||
|
|
||||||
// 创建支付宝订单
|
|
||||||
createOrder(params: {
|
|
||||||
outTradeNo: string
|
|
||||||
subject: string
|
|
||||||
totalAmount: number
|
|
||||||
body?: string
|
|
||||||
}) {
|
|
||||||
const orderInfo = {
|
|
||||||
app_id: this.config.appId,
|
|
||||||
method: "alipay.trade.wap.pay",
|
|
||||||
format: "JSON",
|
|
||||||
charset: "utf-8",
|
|
||||||
sign_type: "MD5",
|
|
||||||
timestamp: new Date().toISOString().slice(0, 19).replace("T", " "),
|
|
||||||
version: "1.0",
|
|
||||||
notify_url: this.config.notifyUrl,
|
|
||||||
return_url: this.config.returnUrl,
|
|
||||||
biz_content: JSON.stringify({
|
|
||||||
out_trade_no: params.outTradeNo,
|
|
||||||
product_code: "QUICK_WAP_WAY",
|
|
||||||
total_amount: params.totalAmount.toFixed(2),
|
|
||||||
subject: params.subject,
|
|
||||||
body: params.body || params.subject,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
const sign = this.generateSign(orderInfo)
|
|
||||||
return {
|
|
||||||
...orderInfo,
|
|
||||||
sign,
|
|
||||||
paymentUrl: this.buildPaymentUrl(orderInfo, sign),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成签名
|
|
||||||
generateSign(params: Record<string, string>): string {
|
|
||||||
const sortedKeys = Object.keys(params).sort()
|
|
||||||
const signString = sortedKeys
|
|
||||||
.filter((key) => params[key] && key !== "sign")
|
|
||||||
.map((key) => `${key}=${params[key]}`)
|
|
||||||
.join("&")
|
|
||||||
|
|
||||||
const signWithKey = `${signString}${this.config.key}`
|
|
||||||
return crypto.createHash("md5").update(signWithKey, "utf8").digest("hex")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证回调签名
|
|
||||||
verifySign(params: Record<string, string>): boolean {
|
|
||||||
const receivedSign = params.sign
|
|
||||||
if (!receivedSign) return false
|
|
||||||
|
|
||||||
const calculatedSign = this.generateSign(params)
|
|
||||||
return receivedSign.toLowerCase() === calculatedSign.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建支付URL
|
|
||||||
private buildPaymentUrl(params: Record<string, string>, sign: string): string {
|
|
||||||
const gateway = "https://openapi.alipay.com/gateway.do"
|
|
||||||
const queryParams = new URLSearchParams({ ...params, sign })
|
|
||||||
return `${gateway}?${queryParams.toString()}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user