From bbdfb54c84de658db5676768d3cfa4fd7c50e345 Mon Sep 17 00:00:00 2001 From: songtianlun Date: Sun, 3 Aug 2025 19:14:06 +0800 Subject: [PATCH] finish 80% subscribe --- .../debug-copy-feature.md | 0 .../enable-stats-after-migration.md | 0 docs/stripe-setup.md | 175 +++++++++++ messages/en.json | 52 +++- messages/zh.json | 52 +++- package-lock.json | 65 ++-- package.json | 2 + prisma/schema.prisma | 5 +- src/app/api/subscription/create/route.ts | 76 +++++ src/app/api/subscription/manage/route.ts | 157 ++++++++++ src/app/api/subscription/sync/route.ts | 90 ++++++ src/app/api/webhooks/stripe/route.ts | 168 ++++++++++ src/app/page.tsx | 105 +++---- src/app/pricing/page.tsx | 186 +++++++++++ src/app/subscription/page.tsx | 294 ++++++++++++++++++ src/components/layout/Header.tsx | 12 +- .../subscription/SubscribeButton.tsx | 110 +++++++ .../subscription/SubscriptionStatus.tsx | 149 +++++++++ src/components/ui/user-avatar-dropdown.tsx | 30 +- src/lib/stripe.ts | 137 ++++++++ src/lib/subscription.ts | 21 +- 21 files changed, 1772 insertions(+), 114 deletions(-) rename debug-copy-feature.md => docs/debug-copy-feature.md (100%) rename enable-stats-after-migration.md => docs/enable-stats-after-migration.md (100%) create mode 100644 docs/stripe-setup.md create mode 100644 src/app/api/subscription/create/route.ts create mode 100644 src/app/api/subscription/manage/route.ts create mode 100644 src/app/api/subscription/sync/route.ts create mode 100644 src/app/api/webhooks/stripe/route.ts create mode 100644 src/app/pricing/page.tsx create mode 100644 src/app/subscription/page.tsx create mode 100644 src/components/subscription/SubscribeButton.tsx create mode 100644 src/components/subscription/SubscriptionStatus.tsx create mode 100644 src/lib/stripe.ts diff --git a/debug-copy-feature.md b/docs/debug-copy-feature.md similarity index 100% rename from debug-copy-feature.md rename to docs/debug-copy-feature.md diff --git a/enable-stats-after-migration.md b/docs/enable-stats-after-migration.md similarity index 100% rename from enable-stats-after-migration.md rename to docs/enable-stats-after-migration.md diff --git a/docs/stripe-setup.md b/docs/stripe-setup.md new file mode 100644 index 0000000..11cf85c --- /dev/null +++ b/docs/stripe-setup.md @@ -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 计划 +- 自动处理订阅状态变更 +- 客户自助管理订阅 +- 安全的支付处理 diff --git a/messages/en.json b/messages/en.json index 2c124ce..072624d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/zh.json b/messages/zh.json index 13b8cd7..f0392e3 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -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": "管理员后台", diff --git a/package-lock.json b/package-lock.json index 570a4ed..927e28f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7470ed9..0cc8dfd 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f47b93..0eadae6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/subscription/create/route.ts b/src/app/api/subscription/create/route.ts new file mode 100644 index 0000000..55e157e --- /dev/null +++ b/src/app/api/subscription/create/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/subscription/manage/route.ts b/src/app/api/subscription/manage/route.ts new file mode 100644 index 0000000..5b8626e --- /dev/null +++ b/src/app/api/subscription/manage/route.ts @@ -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 + 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 } + ) + } +} diff --git a/src/app/api/subscription/sync/route.ts b/src/app/api/subscription/sync/route.ts new file mode 100644 index 0000000..3dacca0 --- /dev/null +++ b/src/app/api/subscription/sync/route.ts @@ -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 + 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 } + ) + } +} diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..d018a0f --- /dev/null +++ b/src/app/api/webhooks/stripe/route.ts @@ -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) + break + + case 'customer.subscription.created': + case 'customer.subscription.updated': + await handleSubscriptionUpdate(event.data.object as unknown as Record) + break + + case 'customer.subscription.deleted': + await handleSubscriptionDeleted(event.data.object as unknown as Record) + break + + case 'invoice.payment_succeeded': + await handlePaymentSucceeded(event.data.object as unknown as Record) + break + + case 'invoice.payment_failed': + await handlePaymentFailed(event.data.object as unknown as Record) + 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) { + try { + const subscriptionId = session.subscription as string + + if (!subscriptionId) { + return + } + + // 获取订阅详情 + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + + // 处理订阅更新 + await handleSubscriptionUpdate(subscription as unknown as Record) + + } catch (error) { + console.error('Error handling checkout session completed:', error) + } +} + +async function handleSubscriptionUpdate(subscription: Record) { + 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) { + 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) { + try { + // 这里可以添加额外的逻辑,比如发送确认邮件等 + + } catch (error) { + console.error('Error handling payment success:', error) + } +} + +async function handlePaymentFailed(_invoice: Record) { + try { + // 这里可以添加额外的逻辑,比如发送提醒邮件等 + + } catch (error) { + console.error('Error handling payment failure:', error) + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 1c5f9db..23364b0 100644 --- a/src/app/page.tsx +++ b/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 (
@@ -92,76 +91,60 @@ export default function Home() {
- {/* Pricing Section */} -
+ {/* Pricing Overview Section */} +
-
+

- {tPricing('title')} + Simple, Transparent Pricing

-

- Choose the plan that fits your needs +

+ Start free, upgrade when you need more

- -
-
-
-

{tPricing('free.title')}

-
{tPricing('free.price')}
-

Perfect for getting started

-
-
    -
  • - - 20 Prompt Limit -
  • -
  • - - 3 Versions per Prompt -
  • -
  • - - $5 AI Credit Monthly -
  • + +
    +
    +

    Free

    +
    $0
    +

    Perfect for getting started

    +
      +
    • 500 Prompts
    • +
    • 3 Versions per Prompt
    -
    - -
    -
    - {tPricing('popular')} + +
    +
    + Popular
    -
    -

    {tPricing('pro.title')}

    -
    {tPricing('pro.price')}
    -

    {tPricing('perMonth')}

    -
    -
      -
    • - - 500 Prompt Limit -
    • -
    • - - 10 Versions per Prompt -
    • -
    • - - $20 AI Credit Monthly -
    • -
    • - - Priority Support -
    • +

      Pro

      +
      $19.9
      +

      per month

      +
        +
      • 5000 Prompts
      • +
      • 10 Versions per Prompt
      • +
      • Priority Support
      -
    + +
    + + +
diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx new file mode 100644 index 0000000..0e77b30 --- /dev/null +++ b/src/app/pricing/page.tsx @@ -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 ( +
+
+ +
+ {/* Header */} +
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+ + {/* Pricing Cards */} +
+ {/* Free Plan */} +
+ {isCurrentPlan('free') && ( +
+ {t('currentPlan')} +
+ )} + +
+
+
+ +
+
+

+ {t('free.title')} +

+
+ {t('free.price')} +
+

+ {t('free.description')} +

+
+ +
    +
  • + + + {SUBSCRIPTION_PLANS.free.promptLimit} Prompt Limit + +
  • +
  • + + + {SUBSCRIPTION_PLANS.free.maxVersionLimit} Versions per Prompt + +
  • +
+ + +
+ + {/* Pro Plan */} +
+
+ {t('popular')} +
+ + {isCurrentPlan('pro') && ( +
+ {t('currentPlan')} +
+ )} + +
+
+
+ +
+
+

+ {t('pro.title')} +

+
+ {t('pro.price')} +
+

+ {t('perMonth')} +

+

+ {t('pro.description')} +

+
+ +
    +
  • + + + {SUBSCRIPTION_PLANS.pro.promptLimit} Prompt Limit + +
  • +
  • + + + {SUBSCRIPTION_PLANS.pro.maxVersionLimit} Versions per Prompt + +
  • +
  • + + Priority Support +
  • +
+ + {isCurrentPlan('pro') ? ( + + ) : ( + + {t('upgradeToPro')} + + )} +
+
+ + {/* Additional Info */} + {user && ( +
+

+ Need to manage your subscription? +

+ +
+ )} +
+
+ ) +} diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx new file mode 100644 index 0000000..1cff21b --- /dev/null +++ b/src/app/subscription/page.tsx @@ -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(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 ( +
+
+
+
+
+ +

{t('loading')}

+
+
+
+
+ ) + } + + if (!user) { + return null + } + + const currentPlan = subscriptionData?.plan || 'free' + + return ( +
+
+ +
+
+
+
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+ +
+
+ +
+ {/* Current Plan */} + + + {/* Quick Actions */} +
+

Quick Actions

+
+ {currentPlan === 'free' ? ( + + + {t('upgradePlan')} + + ) : ( + <> + {/* 简单的账单信息显示 */} + + {subscriptionData?.subscriptions && subscriptionData.subscriptions.length > 0 && ( + + )} + + )} + + +
+
+
+
+
+ ) +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 2d59e28..e23e878 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -38,9 +38,9 @@ export function Header() { {t('plaza')} - - Pricing - + + {t('pricing')} + @@ -95,9 +95,9 @@ export function Header() { {t('plaza')} - - Pricing - + + {t('pricing')} + {/* Mobile Language Toggle */}
diff --git a/src/components/subscription/SubscribeButton.tsx b/src/components/subscription/SubscribeButton.tsx new file mode 100644 index 0000000..efa4d3c --- /dev/null +++ b/src/components/subscription/SubscribeButton.tsx @@ -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 ( + + ) +} + +// 简化版本,用于快速升级 +interface QuickUpgradeButtonProps { + className?: string +} + +export function QuickUpgradeButton({ className }: QuickUpgradeButtonProps) { + return ( + + Upgrade to Pro + + ) +} diff --git a/src/components/subscription/SubscriptionStatus.tsx b/src/components/subscription/SubscriptionStatus.tsx new file mode 100644 index 0000000..6bffe22 --- /dev/null +++ b/src/components/subscription/SubscriptionStatus.tsx @@ -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(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 ( +
+
+
+ ) + } + + const currentPlan = userData?.subscribePlan || 'free' + const isPro = currentPlan === 'pro' + const activeSubscription = subscriptionData?.subscriptions?.find(sub => sub.status === 'active') + + return ( +
+
+
+
+
+ {isPro ? ( + + ) : ( + + )} +
+
+

+ {t('currentPlan')} +

+

+ {isPro ? t('proPlan') : t('freePlan')} +

+
+
+ + {/* Status indicator */} +
+ {activeSubscription?.cancelAtPeriodEnd ? ( +
+ + Canceling +
+ ) : isPro ? ( +
+ + Active +
+ ) : null} +
+
+
+ + {showDetails && ( +
+
+
+ Prompts: + + {userData?.promptLimit || (isPro ? '5000' : '500')} + +
+
+ Versions: + + {userData?.maxVersionLimit || (isPro ? '10' : '3')} per prompt + +
+
+ + {activeSubscription && ( +
+
+ {t('nextBilling')}: + + {new Date(activeSubscription.currentPeriodEnd * 1000).toLocaleDateString()} + +
+ {activeSubscription.cancelAtPeriodEnd && ( +
+ Subscription will end on the next billing date +
+ )} +
+ )} +
+ )} +
+ ) +} diff --git a/src/components/ui/user-avatar-dropdown.tsx b/src/components/ui/user-avatar-dropdown.tsx index f009c79..0165b01 100644 --- a/src/components/ui/user-avatar-dropdown.tsx +++ b/src/components/ui/user-avatar-dropdown.tsx @@ -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({ {t('profile')} + + {isAdmin && ( + + {isAdmin && (