Update remote soul-content with local content
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
.DS_Store
|
||||
.trae/
|
||||
*.log
|
||||
1
.gitignore 2
Normal file
1
.gitignore 2
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
90
DEPLOYMENT.md
Normal file
90
DEPLOYMENT.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 部署指南
|
||||
|
||||
## 生产环境部署步骤
|
||||
|
||||
### 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
Normal file
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 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
SYNC_LOG.md
Normal file
1
SYNC_LOG.md
Normal file
@@ -0,0 +1 @@
|
||||
Mon Dec 29 18:11:24 CST 2025
|
||||
94
addons/Universal_Payment_Module/1_核心设计_通用协议/API接口定义.md
Normal file
94
addons/Universal_Payment_Module/1_核心设计_通用协议/API接口定义.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 通用支付模块 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}`
|
||||
70
addons/Universal_Payment_Module/1_核心设计_通用协议/标准配置模板.yaml
Normal file
70
addons/Universal_Payment_Module/1_核心设计_通用协议/标准配置模板.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
# 全球支付模块标准配置模板 (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 # 轮询间隔 (秒)
|
||||
44
addons/Universal_Payment_Module/2_智能对接_AI指令/通用集成指令.md
Normal file
44
addons/Universal_Payment_Module/2_智能对接_AI指令/通用集成指令.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 通用支付模块智能对接指令 (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` 的核心接口代码。"
|
||||
141
addons/Universal_Payment_Module/3_逻辑参考_通用实现/前端收银台Demo.html
Normal file
141
addons/Universal_Payment_Module/3_逻辑参考_通用实现/前端收银台Demo.html
Normal file
@@ -0,0 +1,141 @@
|
||||
<!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>
|
||||
58
addons/Universal_Payment_Module/README.md
Normal file
58
addons/Universal_Payment_Module/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 全球通用支付模块 (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 收银台,复制即用。
|
||||
152
app/about/page.tsx
Normal file
152
app/about/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
3
app/admin/content/loading.tsx
Normal file
3
app/admin/content/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
335
app/admin/content/page.tsx
Normal file
335
app/admin/content/page.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
77
app/admin/layout.tsx
Normal file
77
app/admin/layout.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
3
app/admin/loading.tsx
Normal file
3
app/admin/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
3
app/admin/login/loading.tsx
Normal file
3
app/admin/login/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
111
app/admin/login/page.tsx
Normal file
111
app/admin/login/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
118
app/admin/page.tsx
Normal file
118
app/admin/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
3
app/admin/payment/loading.tsx
Normal file
3
app/admin/payment/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
375
app/admin/payment/page.tsx
Normal file
375
app/admin/payment/page.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
3
app/admin/qrcodes/loading.tsx
Normal file
3
app/admin/qrcodes/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
225
app/admin/qrcodes/page.tsx
Normal file
225
app/admin/qrcodes/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
3
app/admin/settings/loading.tsx
Normal file
3
app/admin/settings/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
224
app/admin/settings/page.tsx
Normal file
224
app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
3
app/admin/users/loading.tsx
Normal file
3
app/admin/users/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
136
app/admin/users/page.tsx
Normal file
136
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
3
app/admin/withdrawals/loading.tsx
Normal file
3
app/admin/withdrawals/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
172
app/admin/withdrawals/page.tsx
Normal file
172
app/admin/withdrawals/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
55
app/api/config/route.ts
Normal file
55
app/api/config/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
}
|
||||
36
app/api/content/route.ts
Normal file
36
app/api/content/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
55
app/api/documentation/generate/route.ts
Normal file
55
app/api/documentation/generate/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
12
app/api/menu/route.ts
Normal file
12
app/api/menu/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
27
app/api/orders/route.ts
Normal file
27
app/api/orders/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
48
app/api/payment/alipay/notify/route.ts
Normal file
48
app/api/payment/alipay/notify/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
46
app/api/payment/callback/route.ts
Normal file
46
app/api/payment/callback/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
88
app/api/payment/create-order/route.ts
Normal file
88
app/api/payment/create-order/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
46
app/api/payment/verify/route.ts
Normal file
46
app/api/payment/verify/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
59
app/api/payment/wechat/notify/route.ts
Normal file
59
app/api/payment/wechat/notify/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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" },
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
54
app/chapters/page.tsx
Normal file
54
app/chapters/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
112
app/docs/page.tsx
Normal file
112
app/docs/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
3
app/documentation/capture/loading.tsx
Normal file
3
app/documentation/capture/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
78
app/documentation/capture/page.tsx
Normal file
78
app/documentation/capture/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
306
app/documentation/page.tsx
Normal file
306
app/documentation/page.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
"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
Normal file
143
app/globals.css
Normal file
@@ -0,0 +1,143 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
47
app/layout.tsx
Normal file
47
app/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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
Normal file
151
app/my/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
109
app/my/purchases/page.tsx
Normal file
109
app/my/purchases/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
233
app/my/referral/page.tsx
Normal file
233
app/my/referral/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
96
app/my/settings/page.tsx
Normal file
96
app/my/settings/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"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
Normal file
26
app/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
40
app/read/[id]/page.tsx
Normal file
40
app/read/[id]/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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
Submodule
1
book
Submodule
Submodule book added at 7994e19cda
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
225
components/auth-modal.tsx
Normal file
225
components/auth-modal.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
111
components/book-cover.tsx
Normal file
111
components/book-cover.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
34
components/book-intro.tsx
Normal file
34
components/book-intro.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
62
components/bottom-nav.tsx
Normal file
62
components/bottom-nav.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
56
components/buy-full-book-button.tsx
Normal file
56
components/buy-full-book-button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"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()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
290
components/chapter-content.tsx
Normal file
290
components/chapter-content.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
135
components/chapters-list.tsx
Normal file
135
components/chapters-list.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
14
components/config-loader.tsx
Normal file
14
components/config-loader.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useStore } from "@/lib/store"
|
||||
|
||||
export function ConfigLoader() {
|
||||
const { fetchSettings } = useStore()
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [fetchSettings])
|
||||
|
||||
return null
|
||||
}
|
||||
12
components/footer.tsx
Normal file
12
components/footer.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
27
components/layout-wrapper.tsx
Normal file
27
components/layout-wrapper.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
225
components/modules/auth/auth-modal.tsx
Normal file
225
components/modules/auth/auth-modal.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
123
components/modules/marketing/qr-code-modal.tsx
Normal file
123
components/modules/marketing/qr-code-modal.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
343
components/modules/payment/payment-modal.tsx
Normal file
343
components/modules/payment/payment-modal.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
76
components/modules/referral/poster-modal.tsx
Normal file
76
components/modules/referral/poster-modal.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
48
components/modules/referral/referral-share.tsx
Normal file
48
components/modules/referral/referral-share.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
172
components/modules/referral/withdrawal-modal.tsx
Normal file
172
components/modules/referral/withdrawal-modal.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
35
components/party-group-section.tsx
Normal file
35
components/party-group-section.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
384
components/payment-modal.tsx
Normal file
384
components/payment-modal.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
96
components/purchase-section.tsx
Normal file
96
components/purchase-section.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
78
components/qr-code-modal.tsx
Normal file
78
components/qr-code-modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
65
components/table-of-contents.tsx
Normal file
65
components/table-of-contents.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'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>
|
||||
}
|
||||
46
components/ui/badge.tsx
Normal file
46
components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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 }
|
||||
60
components/ui/button.tsx
Normal file
60
components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
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 }
|
||||
76
components/ui/card.tsx
Normal file
76
components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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 }
|
||||
143
components/ui/dialog.tsx
Normal file
143
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'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,
|
||||
}
|
||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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 }
|
||||
22
components/ui/label.tsx
Normal file
22
components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"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 }
|
||||
160
components/ui/select.tsx
Normal file
160
components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"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,
|
||||
}
|
||||
63
components/ui/slider.tsx
Normal file
63
components/ui/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'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 }
|
||||
28
components/ui/switch.tsx
Normal file
28
components/ui/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"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 }
|
||||
117
components/ui/table.tsx
Normal file
117
components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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,
|
||||
}
|
||||
54
components/ui/tabs.tsx
Normal file
54
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"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 }
|
||||
20
components/ui/textarea.tsx
Normal file
20
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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 }
|
||||
107
components/user-menu.tsx
Normal file
107
components/user-menu.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"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
Normal file
676
lib/book-data.ts
Normal file
@@ -0,0 +1,676 @@
|
||||
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
|
||||
}
|
||||
169
lib/book-file-system.ts
Normal file
169
lib/book-file-system.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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
|
||||
}
|
||||
151
lib/documentation/catalog.ts
Normal file
151
lib/documentation/catalog.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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))
|
||||
}
|
||||
272
lib/documentation/docx.ts
Normal file
272
lib/documentation/docx.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
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)
|
||||
}
|
||||
93
lib/documentation/screenshot.ts
Normal file
93
lib/documentation/screenshot.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
39
lib/markdown.ts
Normal file
39
lib/markdown.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
37
lib/modules/marketing/types.ts
Normal file
37
lib/modules/marketing/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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';
|
||||
}
|
||||
26
lib/modules/payment/types.ts
Normal file
26
lib/modules/payment/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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>;
|
||||
}
|
||||
32
lib/modules/referral/types.ts
Normal file
32
lib/modules/referral/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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>;
|
||||
}
|
||||
117
lib/payment-service.ts
Normal file
117
lib/payment-service.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
115
lib/payment-store.ts
Normal file
115
lib/payment-store.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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
|
||||
}
|
||||
75
lib/payment/alipay.ts
Normal file
75
lib/payment/alipay.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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