finish 80% subscribe

This commit is contained in:
songtianlun 2025-08-03 19:14:06 +08:00
parent 6fecf02a46
commit bbdfb54c84
21 changed files with 1772 additions and 114 deletions

175
docs/stripe-setup.md Normal file
View File

@ -0,0 +1,175 @@
# Stripe 支付集成配置指南
本文档详细说明如何为 Prmbr 项目配置 Stripe 支付系统,实现订阅管理功能。
## 1. Stripe 账户设置
### 1.1 创建 Stripe 账户
1. 访问 [Stripe Dashboard](https://dashboard.stripe.com/)
2. 注册或登录 Stripe 账户
3. 完成账户验证(可能需要提供业务信息)
### 1.2 获取 API 密钥
在 Stripe Dashboard 中:
1. 进入 **Developers** → **API keys**
2. 复制以下密钥:
- **Publishable key** (以 `pk_` 开头)
- **Secret key** (以 `sk_` 开头)
## 2. 创建产品和价格
### 2.1 创建 Pro 订阅产品
1. 在 Stripe Dashboard 中,进入 **Products**
2. 点击 **Add product**
3. 填写产品信息:
- **Name**: `Prmbr Pro Plan`
- **Description**: `Professional plan with advanced features`
4. 添加价格:
- **Price**: `$19.90`
- **Billing period**: `Monthly`
- **Currency**: `USD`
5. 保存产品并复制 **Price ID** (以 `price_` 开头)
### 2.2 配置产品元数据(可选)
为产品添加元数据以便识别:
- `plan_type`: `pro`
- `features`: `5000_prompts,10_versions,priority_support`
## 3. 环境变量配置
在项目根目录的 `.env.local` 文件中添加以下环境变量:
```bash
# Stripe 配置
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_your_publishable_key_here"
STRIPE_SECRET_KEY="sk_test_your_secret_key_here"
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID="price_your_pro_price_id_here"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Webhook 密钥(稍后配置)
STRIPE_WEBHOOK_SECRET="whsec_your_webhook_secret_here"
```
## 4. Webhook 配置
### 4.1 创建 Webhook 端点
1. 在 Stripe Dashboard 中,进入 **Developers** → **Webhooks**
2. 点击 **Add endpoint**
3. 设置端点 URL
- **开发环境**: `http://localhost:3000/api/webhooks/stripe`
- **生产环境**: `https://yourdomain.com/api/webhooks/stripe`
### 4.2 选择监听事件
添加以下事件类型:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
### 4.3 获取 Webhook 签名密钥
1. 创建 webhook 后,点击进入详情页面
2. 在 **Signing secret** 部分,点击 **Reveal** 复制密钥
3. 将密钥添加到环境变量 `STRIPE_WEBHOOK_SECRET`
## 5. 客户门户配置
### 5.1 启用客户门户
1. 在 Stripe Dashboard 中,进入 **Settings****Billing** → **Customer portal**
2. 点击 **Activate** 启用客户门户
3. 配置门户设置:
- **Business information**: 填写公司信息
- **Customer information**: 允许客户更新邮箱和地址
- **Subscriptions**: 允许客户取消订阅
- **Payment methods**: 允许客户更新付款方式
### 5.2 自定义门户外观
1. 在 **Branding** 部分上传 Logo
2. 设置主题颜色以匹配您的品牌
3. 配置返回 URL: `https://yourdomain.com/subscription`
## 6. 测试配置
### 6.1 使用测试卡号
Stripe 提供测试卡号用于开发:
- **成功支付**: `4242 4242 4242 4242`
- **需要验证**: `4000 0025 0000 3155`
- **被拒绝**: `4000 0000 0000 0002`
### 6.2 测试订阅流程
1. 启动开发服务器: `npm run dev`
2. 访问价格页面: `http://localhost:3000/pricing`
3. 点击 "Upgrade to Pro" 按钮
4. 使用测试卡号完成支付
5. 验证用户订阅状态是否正确更新
## 7. 生产环境部署
### 7.1 切换到生产密钥
1. 在 Stripe Dashboard 右上角关闭 **Test mode**
2. 获取生产环境的 API 密钥
3. 更新环境变量为生产密钥
### 7.2 更新 Webhook URL
1. 创建新的生产环境 webhook 端点
2. 使用生产域名: `https://yourdomain.com/api/webhooks/stripe`
3. 更新 `STRIPE_WEBHOOK_SECRET` 为生产密钥
### 7.3 验证配置
- 确保所有环境变量正确设置
- 测试完整的订阅和取消流程
- 验证 webhook 事件正常接收和处理
## 8. 常见问题
### 8.1 Webhook 验证失败
- 检查 `STRIPE_WEBHOOK_SECRET` 是否正确
- 确保 webhook URL 可以从外网访问
- 验证请求头中的签名
### 8.2 订阅状态不同步
- 检查 webhook 事件是否正常接收
- 查看服务器日志中的错误信息
- 确保数据库连接正常
### 8.3 支付失败
- 检查 Stripe 密钥是否正确
- 验证产品和价格 ID 是否匹配
- 确保客户信息完整
## 9. 安全注意事项
1. **密钥安全**
- 永远不要在客户端代码中暴露 Secret Key
- 使用环境变量存储敏感信息
- 定期轮换 API 密钥
2. **Webhook 安全**
- 始终验证 webhook 签名
- 使用 HTTPS 端点
- 实现幂等性处理
3. **数据保护**
- 不要存储完整的信用卡信息
- 遵循 PCI DSS 合规要求
- 定期备份订阅数据
## 10. 监控和日志
### 10.1 Stripe Dashboard 监控
- 定期检查支付状态
- 监控失败的支付和订阅
- 查看客户活动日志
### 10.2 应用程序日志
- 记录所有 Stripe API 调用
- 监控 webhook 处理状态
- 设置错误告警
---
配置完成后,您的应用程序将具备完整的订阅管理功能,包括:
- 用户订阅 Pro 计划
- 自动处理订阅状态变更
- 客户自助管理订阅
- 安全的支付处理

View File

@ -3,6 +3,8 @@
"home": "Home",
"studio": "Studio",
"plaza": "Plaza",
"pricing": "Pricing",
"subscription": "Subscription",
"profile": "Profile",
"admin": "Admin",
"signIn": "Sign In",
@ -191,31 +193,63 @@
}
},
"pricing": {
"title": "Pricing Plans",
"title": "Choose Your Plan",
"subtitle": "Select the plan that best fits your needs",
"free": {
"title": "Free",
"price": "$0",
"description": "Perfect for getting started",
"features": [
"20 Prompt Limit",
"3 Versions per Prompt",
"$5 One-time Credit (Expires in 1 Month)",
"$0 Monthly Credit"
"500 Prompt Limit",
"3 Versions per Prompt"
]
},
"pro": {
"title": "Pro",
"price": "$19.9",
"description": "For power users and teams",
"features": [
"500 Prompt Limit",
"5000 Prompt Limit",
"10 Versions per Prompt",
"$20 Monthly Credit",
"Purchase Permanent Credits"
"Priority Support"
]
},
"getStartedFree": "Get Started Free",
"popular": "Popular",
"perMonth": "per month",
"startProTrial": "Start Pro Trial"
"startProTrial": "Upgrade to Pro",
"currentPlan": "Current Plan",
"upgradeToPro": "Upgrade to Pro",
"manageSubscription": "Manage Subscription"
},
"subscription": {
"title": "Subscription Management",
"subtitle": "Manage your subscription and billing",
"currentPlan": "Current Plan",
"planDetails": "Plan Details",
"billingCycle": "Billing Cycle",
"nextBilling": "Next Billing Date",
"monthly": "Monthly",
"yearly": "Yearly",
"upgradePlan": "Upgrade Plan",
"cancelSubscription": "Cancel Subscription",
"confirmCancel": "Are you sure you want to cancel your subscription?",
"cancelConfirm": "Yes, Cancel",
"keepSubscription": "Keep Subscription",
"subscriptionCanceled": "Subscription canceled successfully",
"subscriptionUpdated": "Subscription updated successfully",
"billingHistory": "Billing History",
"downloadInvoice": "Download Invoice",
"noInvoices": "No invoices available",
"loading": "Loading subscription details...",
"error": "Failed to load subscription details",
"freePlan": "Free Plan",
"proPlan": "Pro Plan",
"features": "Features",
"usage": "Usage",
"promptsUsed": "Prompts Used",
"versionsUsed": "Versions Used",
"unlimited": "Unlimited"
},
"admin": {
"dashboard": "Admin Dashboard",

View File

@ -3,6 +3,8 @@
"home": "首页",
"studio": "工作室",
"plaza": "广场",
"pricing": "价格",
"subscription": "订阅",
"profile": "个人资料",
"admin": "管理员后台",
"signIn": "登录",
@ -191,31 +193,63 @@
}
},
"pricing": {
"title": "价格方案",
"title": "选择您的方案",
"subtitle": "选择最适合您需求的方案",
"free": {
"title": "免费版",
"price": "免费",
"description": "适合入门使用",
"features": [
"20 个提示词限制",
"每个提示词 3 个版本",
"注册赠送 5 美元积分1个月后过期",
"每月 0 美元积分"
"500 个提示词限制",
"每个提示词 3 个版本"
]
},
"pro": {
"title": "专业版",
"price": "$19.9",
"description": "适合高级用户和团队",
"features": [
"500 个提示词限制",
"5000 个提示词限制",
"每个提示词 10 个版本",
"每月 20 美元 AI 积分",
"可购买永久积分"
"优先技术支持"
]
},
"getStartedFree": "免费开始",
"popular": "热门",
"perMonth": "每月",
"startProTrial": "开始专业试用"
"startProTrial": "升级到专业版",
"currentPlan": "当前方案",
"upgradeToPro": "升级到专业版",
"manageSubscription": "管理订阅"
},
"subscription": {
"title": "订阅管理",
"subtitle": "管理您的订阅和账单",
"currentPlan": "当前方案",
"planDetails": "方案详情",
"billingCycle": "计费周期",
"nextBilling": "下次计费日期",
"monthly": "月付",
"yearly": "年付",
"upgradePlan": "升级方案",
"cancelSubscription": "取消订阅",
"confirmCancel": "您确定要取消订阅吗?",
"cancelConfirm": "确认取消",
"keepSubscription": "保持订阅",
"subscriptionCanceled": "订阅已成功取消",
"subscriptionUpdated": "订阅已成功更新",
"billingHistory": "账单历史",
"downloadInvoice": "下载发票",
"noInvoices": "暂无发票",
"loading": "加载订阅详情中...",
"error": "加载订阅详情失败",
"freePlan": "免费版",
"proPlan": "专业版",
"features": "功能特性",
"usage": "使用情况",
"promptsUsed": "已使用提示词",
"versionsUsed": "已使用版本",
"unlimited": "无限制"
},
"admin": {
"dashboard": "管理员后台",

65
package-lock.json generated
View File

@ -10,6 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^6.12.0",
"@stripe/stripe-js": "^7.8.0",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/ssr": "^0.6.1",
@ -22,6 +23,7 @@
"prisma": "^6.12.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"stripe": "^18.4.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
@ -1094,6 +1096,15 @@
"resolved": "https://registry.npmjs.org/@stitches/core/-/core-1.2.8.tgz",
"integrity": "sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg=="
},
"node_modules/@stripe/stripe-js": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.8.0.tgz",
"integrity": "sha512-DNXRfYUgkZlrniQORbA/wH8CdFRhiBSE0R56gYU0V5vvpJ9WZwvGrz9tBAZmfq2aTgw6SK7mNpmTizGzLWVezw==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.71.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
@ -1199,6 +1210,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.6.1.tgz",
"integrity": "sha512-QtQgEMvaDzr77Mk3vZ3jWg2/y+D8tExYF7vcJT+wQ8ysuvOeGGjYbZlvj5bHYsj/SpC0bihcisnwPrM4Gp5G4g==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1"
},
@ -2435,7 +2447,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@ -2448,7 +2459,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@ -2819,7 +2829,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@ -2929,7 +2938,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -2938,7 +2946,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -2974,7 +2981,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0"
},
@ -3612,7 +3618,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -3650,7 +3655,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
@ -3674,7 +3678,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@ -3772,7 +3775,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@ -3844,7 +3846,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@ -3871,7 +3872,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@ -4780,7 +4780,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -5068,7 +5067,6 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@ -5432,6 +5430,21 @@
}
]
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -5825,7 +5838,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
@ -5844,7 +5856,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
@ -5860,7 +5871,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@ -5878,7 +5888,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@ -6085,6 +6094,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "18.4.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.4.0.tgz",
"integrity": "sha512-LKFeDnDYo4U/YzNgx2Lc9PT9XgKN0JNF1iQwZxgkS4lOw5NunWCnzyH5RhTlD3clIZnf54h7nyMWkS8VXPmtTQ==",
"license": "MIT",
"dependencies": {
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
},
"peerDependencies": {
"@types/node": ">=12.x.x"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",

View File

@ -21,6 +21,7 @@
},
"dependencies": {
"@prisma/client": "^6.12.0",
"@stripe/stripe-js": "^7.8.0",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/ssr": "^0.6.1",
@ -33,6 +34,7 @@
"prisma": "^6.12.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"stripe": "^18.4.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {

View File

@ -24,8 +24,9 @@ model User {
versionLimit Int @default(3) // 版本数量限制,可在用户配置中设置
subscribePlan String @default("free") // 订阅计划: "free", "pro"
maxVersionLimit Int @default(3) // 基于订阅的最大版本限制
promptLimit Int @default(20) // 提示词数量限制
creditBalance Float @default(5.0) // 信用余额,单位:美元
promptLimit Int @default(500) // 提示词数量限制更新为500
creditBalance Float @default(0.0) // 信用余额,移除默认值
stripeCustomerId String? @unique // Stripe 客户 ID
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { createOrGetStripeCustomer, createSubscriptionSession, STRIPE_CONFIG } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
export async function POST(request: NextRequest) {
try {
const { priceId } = await request.json()
// 验证用户身份 - 使用服务器端客户端
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
},
}
)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 获取用户信息
const userData = await prisma.user.findUnique({
where: { id: user.id },
select: { email: true, username: true }
})
if (!userData) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// 验证价格 ID
if (priceId !== STRIPE_CONFIG.products.pro.priceId) {
return NextResponse.json({ error: 'Invalid price ID' }, { status: 400 })
}
// 创建或获取 Stripe 客户
const customer = await createOrGetStripeCustomer(
user.id,
userData.email,
userData.username || undefined
)
// 创建订阅会话
const session = await createSubscriptionSession(
customer.id,
priceId,
`${process.env.NEXT_PUBLIC_APP_URL}/subscription?success=true`,
`${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`
)
// 保存 Stripe 客户 ID 到数据库
await prisma.user.update({
where: { id: user.id },
data: { stripeCustomerId: customer.id }
})
return NextResponse.json({ sessionId: session.id, url: session.url })
} catch (error) {
console.error('Error creating subscription:', error)
return NextResponse.json(
{ error: 'Failed to create subscription' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,157 @@
import { NextRequest, NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import {
getCustomerSubscriptions,
cancelSubscription,
reactivateSubscription,
createCustomerPortalSession
} from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
// GET - 获取用户订阅信息
export async function GET() {
try {
// 验证用户身份 - 使用服务器端客户端
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
},
}
)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 获取用户的 Stripe 客户 ID
const userData = await prisma.user.findUnique({
where: { id: user.id },
select: { stripeCustomerId: true, subscribePlan: true }
})
if (!userData?.stripeCustomerId) {
return NextResponse.json({
plan: 'free',
status: 'active',
subscriptions: []
})
}
// 获取 Stripe 订阅信息
const subscriptions = await getCustomerSubscriptions(userData.stripeCustomerId)
return NextResponse.json({
plan: userData.subscribePlan,
subscriptions: subscriptions.map(sub => {
const subData = sub as unknown as Record<string, unknown>
const items = subData.items as { data: Array<{ price: { id: string; unit_amount: number; currency: string } }> }
return {
id: sub.id,
status: sub.status,
currentPeriodStart: subData.current_period_start as number,
currentPeriodEnd: subData.current_period_end as number,
cancelAtPeriodEnd: subData.cancel_at_period_end as boolean,
priceId: items?.data[0]?.price?.id,
amount: items?.data[0]?.price?.unit_amount,
currency: items?.data[0]?.price?.currency,
}
})
})
} catch (error) {
console.error('Error fetching subscription:', error)
return NextResponse.json(
{ error: 'Failed to fetch subscription' },
{ status: 500 }
)
}
}
// POST - 管理订阅(取消、重新激活等)
export async function POST(request: NextRequest) {
try {
const { action, subscriptionId } = await request.json()
// 验证用户身份 - 使用服务器端客户端
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
},
}
)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 获取用户的 Stripe 客户 ID
const userData = await prisma.user.findUnique({
where: { id: user.id },
select: { stripeCustomerId: true }
})
if (!userData?.stripeCustomerId) {
return NextResponse.json({ error: 'No customer found' }, { status: 404 })
}
let result
switch (action) {
case 'cancel':
result = await cancelSubscription(subscriptionId)
break
case 'reactivate':
result = await reactivateSubscription(subscriptionId)
break
case 'portal':
try {
result = await createCustomerPortalSession(
userData.stripeCustomerId,
`${process.env.NEXT_PUBLIC_APP_URL}/subscription`
)
return NextResponse.json({ url: result.url })
} catch (portalError) {
console.error('Portal creation error:', portalError)
return NextResponse.json({
error: 'Failed to create portal session',
details: portalError instanceof Error ? portalError.message : 'Unknown error'
}, { status: 500 })
}
default:
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
}
return NextResponse.json({
success: true,
subscription: {
id: result.id,
status: result.status,
cancelAtPeriodEnd: result.cancel_at_period_end
}
})
} catch (error) {
console.error('Error managing subscription:', error)
return NextResponse.json(
{ error: 'Failed to manage subscription' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,90 @@
import { NextResponse } from 'next/server'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { getCustomerSubscriptions, STRIPE_CONFIG } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
export async function POST() {
try {
// 验证用户身份
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
},
}
)
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 获取用户的 Stripe 客户 ID
const userData = await prisma.user.findUnique({
where: { id: user.id },
select: { stripeCustomerId: true }
})
if (!userData?.stripeCustomerId) {
return NextResponse.json({ error: 'No Stripe customer found' }, { status: 404 })
}
// 获取 Stripe 订阅信息
const subscriptions = await getCustomerSubscriptions(userData.stripeCustomerId)
// 查找活跃的订阅
const activeSubscription = subscriptions.find(sub => sub.status === 'active')
let subscribePlan = 'free'
let maxVersionLimit = 3
let promptLimit = 500
if (activeSubscription) {
const subData = activeSubscription as unknown as Record<string, unknown>
const items = subData.items as { data: Array<{ price: { id: string } }> }
const priceId = items?.data[0]?.price?.id
if (priceId === STRIPE_CONFIG.products.pro.priceId) {
subscribePlan = 'pro'
maxVersionLimit = 10
promptLimit = 5000
}
}
// 更新用户订阅状态
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
subscribePlan,
maxVersionLimit,
promptLimit,
}
})
console.log(`Synced user ${user.id} subscription to ${subscribePlan}`)
return NextResponse.json({
success: true,
plan: subscribePlan,
user: {
subscribePlan: updatedUser.subscribePlan,
maxVersionLimit: updatedUser.maxVersionLimit,
promptLimit: updatedUser.promptLimit
}
})
} catch (error) {
console.error('Error syncing subscription:', error)
return NextResponse.json(
{ error: 'Failed to sync subscription' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from 'next/server'
import { stripe, STRIPE_CONFIG } from '@/lib/stripe'
import { prisma } from '@/lib/prisma'
import { headers } from 'next/headers'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const headersList = await headers()
const signature = headersList.get('stripe-signature')
if (!signature) {
return NextResponse.json({ error: 'No signature' }, { status: 400 })
}
// 验证 webhook 签名
const event = stripe.webhooks.constructEvent(
body,
signature,
STRIPE_CONFIG.webhookSecret
)
// 处理不同类型的事件
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutSessionCompleted(event.data.object as unknown as Record<string, unknown>)
break
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object as unknown as Record<string, unknown>)
break
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object as unknown as Record<string, unknown>)
break
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object as unknown as Record<string, unknown>)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as unknown as Record<string, unknown>)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook error:', error)
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 400 }
)
}
}
async function handleCheckoutSessionCompleted(session: Record<string, unknown>) {
try {
const subscriptionId = session.subscription as string
if (!subscriptionId) {
return
}
// 获取订阅详情
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
// 处理订阅更新
await handleSubscriptionUpdate(subscription as unknown as Record<string, unknown>)
} catch (error) {
console.error('Error handling checkout session completed:', error)
}
}
async function handleSubscriptionUpdate(subscription: Record<string, unknown>) {
try {
const customerId = subscription.customer as string
const status = subscription.status as string
const items = subscription.items as { data: Array<{ price: { id: string } }> }
const priceId = items?.data[0]?.price?.id
// 根据价格 ID 确定订阅计划
let subscribePlan = 'free'
if (priceId === STRIPE_CONFIG.products.pro.priceId) {
subscribePlan = 'pro'
}
// 查找用户
const user = await prisma.user.findFirst({
where: { stripeCustomerId: customerId }
})
if (!user) {
console.error('User not found for customer:', customerId)
return
}
// 更新用户订阅状态
await prisma.user.update({
where: { id: user.id },
data: {
subscribePlan: status === 'active' ? subscribePlan : 'free',
// 根据订阅计划更新限制
maxVersionLimit: subscribePlan === 'pro' ? 10 : 3,
promptLimit: subscribePlan === 'pro' ? 5000 : 500,
}
})
} catch (error) {
console.error('Error handling subscription update:', error)
}
}
async function handleSubscriptionDeleted(subscription: Record<string, unknown>) {
try {
const customerId = subscription.customer as string
// 查找用户
const user = await prisma.user.findFirst({
where: { stripeCustomerId: customerId }
})
if (!user) {
console.error('User not found for customer:', customerId)
return
}
// 将用户降级为免费计划
await prisma.user.update({
where: { id: user.id },
data: {
subscribePlan: 'free',
maxVersionLimit: 3,
promptLimit: 500,
}
})
} catch (error) {
console.error('Error handling subscription deletion:', error)
}
}
async function handlePaymentSucceeded(_invoice: Record<string, unknown>) {
try {
// 这里可以添加额外的逻辑,比如发送确认邮件等
} catch (error) {
console.error('Error handling payment success:', error)
}
}
async function handlePaymentFailed(_invoice: Record<string, unknown>) {
try {
// 这里可以添加额外的逻辑,比如发送提醒邮件等
} catch (error) {
console.error('Error handling payment failure:', error)
}
}

View File

@ -4,11 +4,10 @@ import { useTranslations } from 'next-intl'
import { Header } from '@/components/layout/Header'
import { Logo } from '@/components/ui/logo'
import { Button } from '@/components/ui/button'
import { Zap, Target, Layers, BarChart3, Check } from 'lucide-react'
import { Zap, Target, Layers, BarChart3 } from 'lucide-react'
export default function Home() {
const t = useTranslations('home')
const tPricing = useTranslations('pricing')
return (
<div className="min-h-screen">
@ -92,76 +91,60 @@ export default function Home() {
</div>
</section>
{/* Pricing Section */}
<section id="pricing" className="py-24">
{/* Pricing Overview Section */}
<section className="py-24 bg-muted/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-foreground mb-4">
{tPricing('title')}
Simple, Transparent Pricing
</h2>
<p className="text-xl text-muted-foreground">
Choose the plan that fits your needs
<p className="text-xl text-muted-foreground mb-8">
Start free, upgrade when you need more
</p>
</div>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<div className="bg-card p-8 rounded-lg shadow-sm border">
<div className="text-center mb-6">
<h3 className="text-2xl font-bold text-card-foreground mb-2">{tPricing('free.title')}</h3>
<div className="text-4xl font-bold text-card-foreground mb-2">{tPricing('free.price')}</div>
<p className="text-muted-foreground">Perfect for getting started</p>
</div>
<ul className="space-y-3 mb-8">
<li className="flex items-center">
<Check className="h-5 w-5 text-green-500 mr-3" />
<span className="text-card-foreground">20 Prompt Limit</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-green-500 mr-3" />
<span className="text-card-foreground">3 Versions per Prompt</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-green-500 mr-3" />
<span className="text-card-foreground">$5 AI Credit Monthly</span>
</li>
<div className="grid md:grid-cols-2 gap-6 max-w-2xl mx-auto mb-12">
<div className="bg-card p-6 rounded-lg border text-center">
<h3 className="text-xl font-semibold mb-2">Free</h3>
<div className="text-3xl font-bold mb-2">$0</div>
<p className="text-muted-foreground text-sm mb-4">Perfect for getting started</p>
<ul className="text-sm space-y-1 mb-4">
<li>500 Prompts</li>
<li>3 Versions per Prompt</li>
</ul>
<Button className="w-full" onClick={() => window.location.href = '/signup'}>
{tPricing('getStartedFree')}
</Button>
</div>
<div className="bg-primary p-8 rounded-lg shadow-sm text-primary-foreground relative">
<div className="absolute top-4 right-4 bg-primary-foreground text-primary px-3 py-1 rounded-full text-xs font-semibold">
{tPricing('popular')}
<div className="bg-primary text-primary-foreground p-6 rounded-lg text-center relative">
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2 bg-orange-500 text-white px-3 py-1 rounded-full text-xs font-semibold">
Popular
</div>
<div className="text-center mb-6">
<h3 className="text-2xl font-bold mb-2">{tPricing('pro.title')}</h3>
<div className="text-4xl font-bold mb-2">{tPricing('pro.price')}</div>
<p className="text-primary-foreground/80">{tPricing('perMonth')}</p>
</div>
<ul className="space-y-3 mb-8">
<li className="flex items-center">
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>500 Prompt Limit</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>10 Versions per Prompt</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>$20 AI Credit Monthly</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>Priority Support</span>
</li>
<h3 className="text-xl font-semibold mb-2">Pro</h3>
<div className="text-3xl font-bold mb-2">$19.9</div>
<p className="text-primary-foreground/80 text-sm mb-4">per month</p>
<ul className="text-sm space-y-1 mb-4">
<li>5000 Prompts</li>
<li>10 Versions per Prompt</li>
<li>Priority Support</li>
</ul>
<Button variant="outline" className="w-full bg-primary-foreground text-primary hover:bg-primary-foreground/90" onClick={() => window.location.href = '/signup'}>
{tPricing('startProTrial')}
</Button>
</div>
</div>
<div className="text-center">
<Button
size="lg"
onClick={() => window.location.href = '/pricing'}
className="mr-4"
>
View All Plans
</Button>
<Button
variant="outline"
size="lg"
onClick={() => window.location.href = '/signup'}
>
Get Started Free
</Button>
</div>
</div>
</section>

186
src/app/pricing/page.tsx Normal file
View File

@ -0,0 +1,186 @@
'use client'
import { useTranslations } from 'next-intl'
import { useAuth } from '@/hooks/useAuth'
import { useUser } from '@/hooks/useUser'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/ui/button'
import { Check, Crown, Star } from 'lucide-react'
import { SUBSCRIPTION_PLANS } from '@/lib/subscription'
import { SubscribeButton } from '@/components/subscription/SubscribeButton'
export default function PricingPage() {
const { user } = useAuth()
const { userData } = useUser()
const t = useTranslations('pricing')
const handleGetStarted = () => {
if (user) {
window.location.href = '/studio'
} else {
window.location.href = '/signup'
}
}
const isCurrentPlan = (planKey: string) => {
if (!userData) return false
return userData.subscribePlan === planKey
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
{/* Header */}
<div className="text-center mb-16">
<h1 className="text-4xl font-bold text-foreground mb-4">
{t('title')}
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
{t('subtitle')}
</p>
</div>
{/* Pricing Cards */}
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{/* Free Plan */}
<div className="bg-card p-8 rounded-lg shadow-sm border relative">
{isCurrentPlan('free') && (
<div className="absolute top-4 right-4 bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300 px-3 py-1 rounded-full text-xs font-semibold">
{t('currentPlan')}
</div>
)}
<div className="text-center mb-6">
<div className="flex items-center justify-center mb-4">
<div className="p-3 rounded-full bg-gradient-to-br from-slate-400 to-gray-500 dark:from-slate-500 dark:to-gray-400">
<Star className="w-6 h-6 text-white" />
</div>
</div>
<h3 className="text-2xl font-bold text-card-foreground mb-2">
{t('free.title')}
</h3>
<div className="text-4xl font-bold text-card-foreground mb-2">
{t('free.price')}
</div>
<p className="text-muted-foreground">
{t('free.description')}
</p>
</div>
<ul className="space-y-3 mb-8">
<li className="flex items-center">
<Check className="h-5 w-5 text-green-500 mr-3" />
<span className="text-card-foreground">
{SUBSCRIPTION_PLANS.free.promptLimit} Prompt Limit
</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-green-500 mr-3" />
<span className="text-card-foreground">
{SUBSCRIPTION_PLANS.free.maxVersionLimit} Versions per Prompt
</span>
</li>
</ul>
<Button
className="w-full"
variant={isCurrentPlan('free') ? 'outline' : 'default'}
onClick={handleGetStarted}
disabled={isCurrentPlan('free')}
>
{isCurrentPlan('free') ? t('currentPlan') : t('getStartedFree')}
</Button>
</div>
{/* Pro Plan */}
<div className="bg-primary p-8 rounded-lg shadow-sm text-primary-foreground relative">
<div className="absolute top-4 right-4 bg-primary-foreground text-primary px-3 py-1 rounded-full text-xs font-semibold">
{t('popular')}
</div>
{isCurrentPlan('pro') && (
<div className="absolute top-4 left-4 bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300 px-3 py-1 rounded-full text-xs font-semibold">
{t('currentPlan')}
</div>
)}
<div className="text-center mb-6">
<div className="flex items-center justify-center mb-4">
<div className="p-3 rounded-full bg-gradient-to-br from-amber-500 to-orange-500 dark:from-amber-400 dark:to-orange-400">
<Crown className="w-6 h-6 text-white" />
</div>
</div>
<h3 className="text-2xl font-bold mb-2">
{t('pro.title')}
</h3>
<div className="text-4xl font-bold mb-2">
{t('pro.price')}
</div>
<p className="text-primary-foreground/80">
{t('perMonth')}
</p>
<p className="text-primary-foreground/80 mt-2">
{t('pro.description')}
</p>
</div>
<ul className="space-y-3 mb-8">
<li className="flex items-center">
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>
{SUBSCRIPTION_PLANS.pro.promptLimit} Prompt Limit
</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>
{SUBSCRIPTION_PLANS.pro.maxVersionLimit} Versions per Prompt
</span>
</li>
<li className="flex items-center">
<Check className="h-5 w-5 text-primary-foreground/80 mr-3" />
<span>Priority Support</span>
</li>
</ul>
{isCurrentPlan('pro') ? (
<Button
variant="outline"
className="w-full bg-primary-foreground text-primary"
disabled
>
{t('currentPlan')}
</Button>
) : (
<SubscribeButton
priceId={process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID || ''}
planName="Pro"
variant="outline"
className="w-full bg-primary-foreground text-primary hover:bg-primary-foreground/90"
>
{t('upgradeToPro')}
</SubscribeButton>
)}
</div>
</div>
{/* Additional Info */}
{user && (
<div className="text-center mt-12">
<p className="text-muted-foreground mb-4">
Need to manage your subscription?
</p>
<Button
variant="outline"
onClick={() => window.location.href = '/subscription'}
>
{t('manageSubscription')}
</Button>
</div>
)}
</main>
</div>
)
}

View File

@ -0,0 +1,294 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useAuth } from '@/hooks/useAuth'
import { useUser } from '@/hooks/useUser'
import { useRouter } from 'next/navigation'
import { Header } from '@/components/layout/Header'
import { Button } from '@/components/ui/button'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import { Crown, Star, CreditCard, AlertTriangle } from 'lucide-react'
import { SubscriptionStatus } from '@/components/subscription/SubscriptionStatus'
import { SubscribeButton } from '@/components/subscription/SubscribeButton'
interface SubscriptionData {
plan: string
status?: string
subscriptions: Array<{
id: string
status: string
currentPeriodEnd: number
cancelAtPeriodEnd: boolean
}>
}
export default function SubscriptionPage() {
const { user, loading: authLoading } = useAuth()
const { userData, loading: userLoading } = useUser()
const router = useRouter()
const t = useTranslations('subscription')
const [subscriptionData, setSubscriptionData] = useState<SubscriptionData | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(false)
useEffect(() => {
if (!authLoading && !user) {
router.push('/signin')
return
}
const fetchSubscriptionData = async () => {
if (!userData) return
try {
// 检查是否是从支付成功页面返回
const urlParams = new URLSearchParams(window.location.search)
const isSuccess = urlParams.get('success') === 'true'
if (isSuccess) {
// 先同步订阅状态
try {
const syncResponse = await fetch('/api/subscription/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
if (syncResponse.ok) {
// 清除 URL 参数
window.history.replaceState({}, '', '/subscription')
}
} catch (syncError) {
console.error('Failed to sync subscription:', syncError)
}
}
const response = await fetch('/api/subscription/manage')
if (response.ok) {
const data = await response.json()
setSubscriptionData(data)
}
} catch (error) {
console.error('Failed to fetch subscription data:', error)
} finally {
setLoading(false)
}
}
if (userData) {
fetchSubscriptionData()
}
}, [user, userData, authLoading, router])
const handleCancelSubscription = async (subscriptionId: string) => {
if (!confirm(t('confirmCancel'))) return
setActionLoading(true)
try {
const response = await fetch('/api/subscription/manage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'cancel',
subscriptionId
}),
})
if (response.ok) {
// 刷新订阅数据
window.location.reload()
} else {
throw new Error('Failed to cancel subscription')
}
} catch (error) {
console.error('Cancel failed:', error)
alert('Failed to cancel subscription. Please try again.')
} finally {
setActionLoading(false)
}
}
const handleManageSubscription = async () => {
setActionLoading(true)
try {
const response = await fetch('/api/subscription/manage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'portal'
}),
})
if (response.ok) {
const { url } = await response.json()
window.location.href = url
} else {
const errorData = await response.json()
console.error('Portal error:', errorData)
// 如果是客户门户未配置的错误,提供替代方案
if (errorData.details?.includes('billing portal') || errorData.details?.includes('portal')) {
alert('Billing portal is not yet configured. Please contact support for subscription management.')
} else {
throw new Error(errorData.error || 'Failed to create portal session')
}
}
} catch (error) {
console.error('Portal failed:', error)
alert('Failed to open billing portal. Please try again.')
} finally {
setActionLoading(false)
}
}
const handleSyncSubscription = async () => {
setActionLoading(true)
try {
const response = await fetch('/api/subscription/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
const result = await response.json()
console.log('Sync result:', result)
// 刷新页面以显示最新状态
window.location.reload()
} else {
throw new Error('Failed to sync subscription')
}
} catch (error) {
console.error('Sync failed:', error)
alert('Failed to sync subscription. Please try again.')
} finally {
setActionLoading(false)
}
}
if (authLoading || userLoading || loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-center min-h-96">
<div className="flex flex-col items-center gap-3">
<LoadingSpinner />
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</div>
</div>
</div>
</div>
)
}
if (!user) {
return null
}
const currentPlan = subscriptionData?.plan || 'free'
return (
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground mb-2">
{t('title')}
</h1>
<p className="text-muted-foreground">
{t('subtitle')}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleSyncSubscription}
disabled={actionLoading}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
{actionLoading ? (
<LoadingSpinner className="w-4 h-4" />
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)}
Refresh
</Button>
</div>
</div>
<div className="grid gap-6">
{/* Current Plan */}
<SubscriptionStatus />
{/* Quick Actions */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="font-semibold text-foreground mb-4">Quick Actions</h3>
<div className="flex flex-col sm:flex-row gap-3">
{currentPlan === 'free' ? (
<SubscribeButton
priceId={process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID || ''}
planName="Pro"
className="flex items-center"
>
<Crown className="w-4 h-4 mr-2" />
{t('upgradePlan')}
</SubscribeButton>
) : (
<>
{/* 简单的账单信息显示 */}
<Button
variant="outline"
onClick={() => {
const message = currentPlan === 'pro'
? 'You are subscribed to the Pro plan ($19.9/month). To make changes to your subscription, please contact support.'
: 'You are on the Free plan. Upgrade to Pro for more features!'
alert(message)
}}
className="flex items-center"
>
<CreditCard className="w-4 h-4 mr-2" />
Billing Info
</Button>
{subscriptionData?.subscriptions && subscriptionData.subscriptions.length > 0 && (
<Button
variant="destructive"
onClick={() => handleCancelSubscription(subscriptionData.subscriptions[0].id)}
disabled={actionLoading}
className="flex items-center"
>
{actionLoading ? <LoadingSpinner className="w-4 h-4 mr-2" /> : <AlertTriangle className="w-4 h-4 mr-2" />}
{t('cancelSubscription')}
</Button>
)}
</>
)}
<Button
variant="outline"
onClick={() => router.push('/pricing')}
className="flex items-center"
>
<Star className="w-4 h-4 mr-2" />
View All Plans
</Button>
</div>
</div>
</div>
</main>
</div>
)
}

View File

@ -38,9 +38,9 @@ export function Header() {
<Link href="/plaza" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
{t('plaza')}
</Link>
<a href="#pricing" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
Pricing
</a>
<Link href="/pricing" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
{t('pricing')}
</Link>
</div>
</div>
@ -95,9 +95,9 @@ export function Header() {
<Link href="/plaza" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
{t('plaza')}
</Link>
<a href="#pricing" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
Pricing
</a>
<Link href="/pricing" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
{t('pricing')}
</Link>
{/* Mobile Language Toggle */}
<div className="pt-4 pb-2 border-t">

View File

@ -0,0 +1,110 @@
'use client'
import { useState } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
interface SubscribeButtonProps {
priceId: string
planName: string
className?: string
children: React.ReactNode
disabled?: boolean
variant?: 'default' | 'outline' | 'ghost' | 'destructive'
}
export function SubscribeButton({
priceId,
className,
children,
disabled = false,
variant = 'default'
}: SubscribeButtonProps) {
const { user, loading: authLoading } = useAuth()
const [loading, setLoading] = useState(false)
const handleSubscribe = async () => {
// 如果还在加载认证状态,不执行任何操作
if (authLoading) {
return
}
if (!user) {
window.location.href = '/signin'
return
}
setLoading(true)
try {
// 创建订阅会话
const response = await fetch('/api/subscription/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceId }),
})
if (!response.ok) {
throw new Error('Failed to create subscription')
}
const { url } = await response.json()
// 重定向到 Stripe Checkout
if (url) {
window.location.href = url
}
} catch (error) {
console.error('Subscription error:', error)
alert('Failed to start subscription process. Please try again.')
} finally {
setLoading(false)
}
}
return (
<Button
variant={variant}
className={className}
onClick={handleSubscribe}
disabled={disabled || loading || authLoading}
>
{loading ? (
<>
<LoadingSpinner className="w-4 h-4 mr-2" />
Processing...
</>
) : authLoading ? (
<>
<LoadingSpinner className="w-4 h-4 mr-2" />
Loading...
</>
) : (
children
)}
</Button>
)
}
// 简化版本,用于快速升级
interface QuickUpgradeButtonProps {
className?: string
}
export function QuickUpgradeButton({ className }: QuickUpgradeButtonProps) {
return (
<SubscribeButton
priceId={process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID || ''}
planName="Pro"
className={className}
variant="default"
>
Upgrade to Pro
</SubscribeButton>
)
}

View File

@ -0,0 +1,149 @@
'use client'
import { useState, useEffect } from 'react'
import { useUser } from '@/hooks/useUser'
import { useTranslations } from 'next-intl'
import { Crown, Star, AlertTriangle, CheckCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SubscriptionData {
plan: string
status: string
subscriptions: Array<{
id: string
status: string
currentPeriodEnd: number
cancelAtPeriodEnd: boolean
}>
}
interface SubscriptionStatusProps {
className?: string
showDetails?: boolean
}
export function SubscriptionStatus({ className, showDetails = true }: SubscriptionStatusProps) {
const { userData } = useUser()
const t = useTranslations('subscription')
const [subscriptionData, setSubscriptionData] = useState<SubscriptionData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchSubscriptionData = async () => {
try {
const response = await fetch('/api/subscription/manage')
if (response.ok) {
const data = await response.json()
setSubscriptionData(data)
}
} catch (error) {
console.error('Failed to fetch subscription data:', error)
} finally {
setLoading(false)
}
}
if (userData) {
fetchSubscriptionData()
} else {
setLoading(false)
}
}, [userData])
if (loading) {
return (
<div className={cn("animate-pulse", className)}>
<div className="h-16 bg-muted rounded-lg"></div>
</div>
)
}
const currentPlan = userData?.subscribePlan || 'free'
const isPro = currentPlan === 'pro'
const activeSubscription = subscriptionData?.subscriptions?.find(sub => sub.status === 'active')
return (
<div className={cn("bg-card rounded-lg border border-border overflow-hidden", className)}>
<div className={`p-4 border-b border-border ${isPro
? 'bg-gradient-to-r from-amber-50/60 to-orange-50/60 dark:from-amber-950/10 dark:to-orange-950/10'
: 'bg-gradient-to-r from-slate-50/60 to-gray-50/60 dark:from-slate-950/5 dark:to-gray-950/5'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-full ${isPro
? 'bg-gradient-to-br from-amber-500 to-orange-500 dark:from-amber-400 dark:to-orange-400'
: 'bg-gradient-to-br from-slate-400 to-gray-500 dark:from-slate-500 dark:to-gray-400'
}`}>
{isPro ? (
<Crown className="w-5 h-5 text-white" />
) : (
<Star className="w-5 h-5 text-white" />
)}
</div>
<div>
<h3 className="font-semibold text-foreground">
{t('currentPlan')}
</h3>
<p className={`text-sm font-medium ${isPro
? 'text-orange-700 dark:text-orange-300'
: 'text-slate-600 dark:text-slate-400'
}`}>
{isPro ? t('proPlan') : t('freePlan')}
</p>
</div>
</div>
{/* Status indicator */}
<div className="flex items-center space-x-2">
{activeSubscription?.cancelAtPeriodEnd ? (
<div className="flex items-center text-orange-600 dark:text-orange-400">
<AlertTriangle className="w-4 h-4 mr-1" />
<span className="text-xs font-medium">Canceling</span>
</div>
) : isPro ? (
<div className="flex items-center text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4 mr-1" />
<span className="text-xs font-medium">Active</span>
</div>
) : null}
</div>
</div>
</div>
{showDetails && (
<div className="p-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Prompts:</span>
<span className="ml-2 font-medium">
{userData?.promptLimit || (isPro ? '5000' : '500')}
</span>
</div>
<div>
<span className="text-muted-foreground">Versions:</span>
<span className="ml-2 font-medium">
{userData?.maxVersionLimit || (isPro ? '10' : '3')} per prompt
</span>
</div>
</div>
{activeSubscription && (
<div className="mt-4 pt-4 border-t border-border">
<div className="text-sm">
<span className="text-muted-foreground">{t('nextBilling')}:</span>
<span className="ml-2 font-medium">
{new Date(activeSubscription.currentPeriodEnd * 1000).toLocaleDateString()}
</span>
</div>
{activeSubscription.cancelAtPeriodEnd && (
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
Subscription will end on the next billing date
</div>
)}
</div>
)}
</div>
)}
</div>
)
}

View File

@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'
import { User as SupabaseUser } from '@supabase/supabase-js'
import { Button } from '@/components/ui/button'
import { LegacyAvatar } from '@/components/ui/avatar'
import { ChevronDown, User, LogOut, Settings } from 'lucide-react'
import { ChevronDown, User, LogOut, Settings, CreditCard } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUser } from '@/hooks/useUser'
import { useRouter } from 'next/navigation'
@ -71,6 +71,18 @@ export function MobileUserMenu({
<span>{t('profile')}</span>
</button>
<button
className={cn(
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground focus:outline-none"
)}
onClick={() => router.push('/subscription')}
>
<CreditCard className="h-4 w-4" />
<span>{t('subscription')}</span>
</button>
{isAdmin && (
<button
className={cn(
@ -202,6 +214,22 @@ export function UserAvatarDropdown({
<span>{t('profile')}</span>
</button>
<button
className={cn(
"w-full flex items-center gap-3 px-3 py-2 text-sm rounded-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground focus:outline-none"
)}
onClick={() => {
router.push('/subscription')
setIsOpen(false)
}}
role="menuitem"
>
<CreditCard className="h-4 w-4" />
<span>{t('subscription')}</span>
</button>
{isAdmin && (
<button
className={cn(

137
src/lib/stripe.ts Normal file
View File

@ -0,0 +1,137 @@
import Stripe from 'stripe'
import { loadStripe, Stripe as StripeJS } from '@stripe/stripe-js'
// Server-side Stripe instance
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_dummy', {
apiVersion: '2025-07-30.basil',
})
// Client-side Stripe instance
let stripePromise: Promise<StripeJS | null>
export const getStripe = () => {
if (!stripePromise) {
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_dummy')
}
return stripePromise
}
// Stripe 产品和价格配置
export const STRIPE_CONFIG = {
products: {
pro: {
priceId: process.env.STRIPE_PRO_PRICE_ID || 'price_dummy', // 需要在 Stripe Dashboard 中创建
name: 'Pro Plan',
amount: 1990, // $19.90 in cents
currency: 'usd',
interval: 'month' as const,
}
},
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || 'whsec_dummy',
}
// 创建订阅会话
export async function createSubscriptionSession(
customerId: string,
priceId: string,
successUrl: string,
cancelUrl: string
) {
const session = await stripe.checkout.sessions.create({
customer: customerId,
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
success_url: successUrl,
cancel_url: cancelUrl,
allow_promotion_codes: true,
billing_address_collection: 'required',
})
return session
}
// 创建或获取 Stripe 客户
export async function createOrGetStripeCustomer(
userId: string,
email: string,
name?: string
) {
// 首先尝试查找现有客户
const existingCustomers = await stripe.customers.list({
email: email,
limit: 1,
})
if (existingCustomers.data.length > 0) {
return existingCustomers.data[0]
}
// 创建新客户
const customer = await stripe.customers.create({
email,
name,
metadata: {
userId,
},
})
return customer
}
// 获取客户的订阅信息
export async function getCustomerSubscriptions(customerId: string) {
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
expand: ['data.default_payment_method'],
})
return subscriptions.data
}
// 取消订阅
export async function cancelSubscription(subscriptionId: string) {
const subscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
})
return subscription
}
// 立即取消订阅
export async function cancelSubscriptionImmediately(subscriptionId: string) {
const subscription = await stripe.subscriptions.cancel(subscriptionId)
return subscription
}
// 重新激活订阅
export async function reactivateSubscription(subscriptionId: string) {
const subscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
})
return subscription
}
// 创建客户门户会话
export async function createCustomerPortalSession(
customerId: string,
returnUrl: string
) {
try {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
})
return session
} catch (error) {
console.error('Stripe portal session creation failed:', error)
throw error
}
}

View File

@ -3,18 +3,23 @@ export const SUBSCRIPTION_PLANS = {
free: {
name: 'Free',
maxVersionLimit: 3,
promptLimit: 20,
monthlyCredit: 0, // 免费版没有月度额度
initialCredit: 5, // 注册时一次性赠送5USD1个月后过期
price: 0
promptLimit: 500, // 放宽限制到500
price: 0,
features: [
'promptLimit', // 500 Prompt Limit
'versionsPerPrompt' // 3 Versions per Prompt
]
},
pro: {
name: 'Pro',
maxVersionLimit: 10,
promptLimit: 500,
monthlyCredit: 20, // 每月20USD额度
initialCredit: 0, // Pro用户不需要初始额度
price: 19.9
promptLimit: 5000, // Pro版本5000个提示词限制
price: 19.9,
features: [
'promptLimit', // 5000 Prompt Limit
'versionsPerPrompt', // 10 Versions per Prompt
'prioritySupport' // Priority Support
]
}
} as const