Initial commit to prepare for remote overwrite

This commit is contained in:
卡若
2026-01-09 11:57:57 +08:00
commit 924307a470
172 changed files with 16577 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
.next/
.env.local
.DS_Store
.trae/
*.log

1
.gitignore 2 Normal file
View File

@@ -0,0 +1 @@
node_modules/

90
DEPLOYMENT.md Normal file
View 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. 启用HTTPSVercel自动配置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
View File

@@ -0,0 +1,30 @@
# AI phone branding
*Automatically synced with your [v0.app](https://v0.app) deployments*
[![Deployed on Vercel](https://img.shields.io/badge/Deployed%20on-Vercel-black?style=for-the-badge&logo=vercel)](https://vercel.com/fnvtks-projects/v0--ap)
[![Built with v0](https://img.shields.io/badge/Built%20with-v0.app-black?style=for-the-badge)](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
View File

@@ -0,0 +1 @@
Mon Dec 29 18:11:24 CST 2025

View 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}`

View 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 # 轮询间隔 (秒)

View 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` 的核心接口代码。"

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

View 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
View 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>
69,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>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

335
app/admin/content/page.tsx Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

111
app/admin/login/page.tsx Normal file
View 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
View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

375
app/admin/payment/page.tsx Normal file
View 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">USDTPayPal等支付参数</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>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

225
app/admin/qrcodes/page.tsx Normal file
View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

224
app/admin/settings/page.tsx Normal file
View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

136
app/admin/users/page.tsx Normal file
View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

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

Submodule book added at 7994e19cda

21
components.json Normal file
View 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
View 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
View 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
View 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
View 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)} />
</>
)
}

View 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()
}}
/>
</>
)
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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">
6Soul开播?
</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>
)
}

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

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

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

View 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
View 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.'
}
}
}

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

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

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