finish 80% subscribe
This commit is contained in:
parent
6fecf02a46
commit
bbdfb54c84
175
docs/stripe-setup.md
Normal file
175
docs/stripe-setup.md
Normal 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 计划
|
||||
- 自动处理订阅状态变更
|
||||
- 客户自助管理订阅
|
||||
- 安全的支付处理
|
@ -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",
|
||||
|
@ -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
65
package-lock.json
generated
@ -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",
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
||||
|
76
src/app/api/subscription/create/route.ts
Normal file
76
src/app/api/subscription/create/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
157
src/app/api/subscription/manage/route.ts
Normal file
157
src/app/api/subscription/manage/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
90
src/app/api/subscription/sync/route.ts
Normal file
90
src/app/api/subscription/sync/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
168
src/app/api/webhooks/stripe/route.ts
Normal file
168
src/app/api/webhooks/stripe/route.ts
Normal 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)
|
||||
}
|
||||
}
|
105
src/app/page.tsx
105
src/app/page.tsx
@ -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
186
src/app/pricing/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
294
src/app/subscription/page.tsx
Normal file
294
src/app/subscription/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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">
|
||||
|
110
src/components/subscription/SubscribeButton.tsx
Normal file
110
src/components/subscription/SubscribeButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
149
src/components/subscription/SubscriptionStatus.tsx
Normal file
149
src/components/subscription/SubscriptionStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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
137
src/lib/stripe.ts
Normal 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
|
||||
}
|
||||
}
|
@ -3,18 +3,23 @@ export const SUBSCRIPTION_PLANS = {
|
||||
free: {
|
||||
name: 'Free',
|
||||
maxVersionLimit: 3,
|
||||
promptLimit: 20,
|
||||
monthlyCredit: 0, // 免费版没有月度额度
|
||||
initialCredit: 5, // 注册时一次性赠送5USD,1个月后过期
|
||||
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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user