diff --git a/env-example/.env.development.example b/env-example/.env.development.example index 7257f7d8..74aedaa3 100644 --- a/env-example/.env.development.example +++ b/env-example/.env.development.example @@ -29,22 +29,3 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad # Cloudflare CDN(test / production 部署后刷缓存用) CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f - -# ───────────────────────────── -# Stripe 支付(dev 环境 test keys —— **查** https://dashboard.stripe.com/test/apikeys 拿**到**) -# ───────────────────────────── -# 服务**端** secret key(**仅** Next.js API route **用**,**不**要**曝**露**给**前**端**) -STRIPE_SECRET_KEY=sk_test_replace_with_your_dev_secret_key -# 前**端** publishable key(NEXT_PUBLIC_ 前**缀**才**能**在**前**端**用**) -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R -# webhook 签**名**密**钥**(**从** `stripe listen` 输**出**拿**到**,**本**地**开**发**用**) -STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_stripe_cli_output - -# 3 **个**订**阅**套餐**的** Product / Price ID(**从** Stripe dashboard **创**建** 3 **个** Product + 3 **个** Price **后**填**) -STRIPE_PRODUCT_MONTHLY=prod_replace_monthly -STRIPE_PRICE_MONTHLY=price_replace_monthly -STRIPE_PRODUCT_QUARTERLY=prod_replace_quarterly -STRIPE_PRICE_QUARTERLY=price_replace_quarterly -STRIPE_PRODUCT_ANNUAL=prod_replace_annual -STRIPE_PRICE_ANNUAL=price_replace_annual - diff --git a/env-example/.env.local.example b/env-example/.env.local.example index ca85ed09..b0596f29 100644 --- a/env-example/.env.local.example +++ b/env-example/.env.local.example @@ -23,18 +23,3 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad # Cloudflare CDN(test / production 部署后刷缓存用) CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f - -# ───────────────────────────── -# Stripe 支付(test 环境 test keys) -# ───────────────────────────── -STRIPE_SECRET_KEY=sk_test_replace_with_your_test_secret_key -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R -STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_stripe_cli_output - -STRIPE_PRODUCT_MONTHLY=prod_replace_monthly -STRIPE_PRICE_MONTHLY=price_replace_monthly -STRIPE_PRODUCT_QUARTERLY=prod_replace_quarterly -STRIPE_PRICE_QUARTERLY=price_replace_quarterly -STRIPE_PRODUCT_ANNUAL=prod_replace_annual -STRIPE_PRICE_ANNUAL=price_replace_annual - diff --git a/env-example/.env.production.example b/env-example/.env.production.example index 5e272cb0..a3a41a69 100644 --- a/env-example/.env.production.example +++ b/env-example/.env.production.example @@ -30,20 +30,3 @@ AUTH_FACEBOOK_SECRET=004b1a9384b3433e153992e5edcef4ad # Cloudflare CDN(test / production 部署后刷缓存用) CF_ZONE_ID=b1b6bfb7795667609c8e7f418af0156b CF_API_TOKEN=cfut_xyHb70XCw0j8f5Wz5fg5PemUQ2ZHnSpWvmrBvVeK94d3b00f - -# ───────────────────────────── -# Stripe 支付(**生**产**环**境** live keys —— **从** https://dashboard.stripe.com/apikeys 拿**到**) -# **不**要** commit 真**实** key 到**版**本**控**制** -# ───────────────────────────── -STRIPE_SECRET_KEY=sk_live_replace_with_your_live_secret_key -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_replace_with_your_dev_publishable_key -# webhook 签**名**密**钥**(**从** Stripe dashboard / Webhooks **端**点**的** "Signing secret" **拿**到**) -STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_live_webhook_signing_secret - -STRIPE_PRODUCT_MONTHLY=prod_replace_monthly -STRIPE_PRICE_MONTHLY=price_replace_monthly -STRIPE_PRODUCT_QUARTERLY=prod_replace_quarterly -STRIPE_PRICE_QUARTERLY=price_replace_quarterly -STRIPE_PRODUCT_ANNUAL=prod_replace_annual -STRIPE_PRICE_ANNUAL=price_replace_annual - diff --git a/package.json b/package.json index e8234cc8..e455ddf5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ }, "dependencies": { "@fingerprintjs/fingerprintjs": "^5.2.0", - "@stripe/stripe-js": "^9.8.0", "@xstate/react": "^5", "classnames": "^2.5.1", "dexie": "^4.4.3", @@ -32,7 +31,6 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-icons": "^5.6.0", - "stripe": "^22.2.1", "ua-parser-js": "^2.0.10", "unstorage": "^1.17.5", "xstate": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9745eeaa..85e5f096 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@fingerprintjs/fingerprintjs': specifier: ^5.2.0 version: 5.2.0 - '@stripe/stripe-js': - specifier: ^9.8.0 - version: 9.8.0 '@xstate/react': specifier: ^5 version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0) @@ -50,9 +47,6 @@ importers: react-icons: specifier: ^5.6.0 version: 5.6.0(react@19.2.4) - stripe: - specifier: ^22.2.1 - version: 22.2.1(@types/node@20.19.41) ua-parser-js: specifier: ^2.0.10 version: 2.0.10 @@ -896,10 +890,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@stripe/stripe-js@9.8.0': - resolution: {integrity: sha512-DHJpol/98VKyojNSYmpkB5vOMnlf87hPe0wPxyaYTNiTMk5QjKMXDfSZLwGctYIXAgAWDFeRABc8lFAj0BELyw==} - engines: {node: '>=12.16'} - '@swc/core-darwin-arm64@1.15.32': resolution: {integrity: sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==} engines: {node: '>=10'} @@ -2944,15 +2934,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - stripe@22.2.1: - resolution: {integrity: sha512-ULAtq25USBEx3yeN5zimEkfYVFETVMP5om25Ryr8ol11P62imaCZLBIEW78T7zm7Wg3wnZi+80S72yf3LCpNhw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3989,8 +3970,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stripe/stripe-js@9.8.0': {} - '@swc/core-darwin-arm64@1.15.32': optional: true @@ -6236,10 +6215,6 @@ snapshots: strip-json-comments@3.1.1: {} - stripe@22.2.1(@types/node@20.19.41): - optionalDependencies: - '@types/node': 20.19.41 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): dependencies: client-only: 0.0.1 diff --git a/src/app/api/payment/create-checkout-session/route.ts b/src/app/api/payment/create-checkout-session/route.ts deleted file mode 100644 index 131199a8..00000000 --- a/src/app/api/payment/create-checkout-session/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * POST /api/payment/create-checkout-session - * - * 创建 Stripe Checkout Session(订阅模式)→ 返回 URL 给前端 - * 前端接到后 `window.location.href = url` 跳转到 Stripe 托管页 - * - * 业务流程: - * 1. 前端用户点 "Confirm the agreement and Activate" - * 2. 前端 POST `{ planId, successUrl, cancelUrl }` 到本端点 - * 3. 本端调 `stripe.checkout.sessions.create({...})` 创建 session - * 4. 返回 `{ url: session.url }` 给前端 - * 5. 前端 `window.location.href = url` 跳转 - * 6. Stripe 收银台结束后回跳到 success_url / cancel_url - * 7. 同时 Stripe 发送 `checkout.session.completed` webhook → 后端设置 user.isVip + voiceMinutes - */ -import { NextRequest, NextResponse } from "next/server"; - -import { SUBSCRIPTION_PLANS } from "@/data/constants/subscription-plans"; -import type { SubscriptionPlanId } from "@/data/constants/subscription-plans"; -import { getPlanPriceId } from "@/lib/stripe/stripe-products"; -import { getStripeClient } from "@/lib/stripe/stripe-client"; -import { Logger } from "@/utils"; - -const log = new Logger("AppApiPaymentCreateCheckoutSessionRoute"); - -interface CreateCheckoutRequestBody { - planId: SubscriptionPlanId; - successUrl: string; - cancelUrl: string; -} - -export async function POST(req: NextRequest) { - let body: CreateCheckoutRequestBody; - try { - body = (await req.json()) as CreateCheckoutRequestBody; - } catch { - return NextResponse.json( - { error: "Invalid JSON body" }, - { status: 400 }, - ); - } - - // 入参验证 - if (!body.planId || !body.successUrl || !body.cancelUrl) { - return NextResponse.json( - { error: "Missing required fields: planId, successUrl, cancelUrl" }, - { status: 400 }, - ); - } - if (!SUBSCRIPTION_PLANS.find((p) => p.id === body.planId)) { - return NextResponse.json( - { error: `Unknown planId: ${body.planId}` }, - { status: 400 }, - ); - } - - // 验证 successUrl / cancelUrl 来自同源(防 open redirect) - // 注:本轮先不做验证(前端和后端同源)—— 未来如果要开放给第三方请加 `same-origin` 检查 - - // 拿到 Stripe Price ID(从 env) - let priceId: string; - try { - priceId = getPlanPriceId(body.planId); - } catch (e) { - return NextResponse.json( - { error: (e as Error).message }, - { status: 500 }, - ); - } - - // 调 Stripe API 创建 Checkout Session - const stripe = getStripeClient(); - try { - const session = await stripe.checkout.sessions.create({ - mode: "subscription", - line_items: [{ price: priceId, quantity: 1 }], - success_url: body.successUrl, - cancel_url: body.cancelUrl, - // 让 webhook 能识别用户 + 套餐(后端接收 webhook 时从 metadata 取) - metadata: { - planId: body.planId, - // TODO: 加 userId(如果已知)—— 前端可在 body 传 userId - }, - // 允许 promo codes(如果 Stripe dashboard 开了) - allow_promotion_codes: true, - // billing_address_collection: "auto" —— 根据 Stripe dashboard 配置 - }); - - return NextResponse.json({ url: session.url }); - } catch (e) { - const err = e as { message?: string; type?: string; code?: string }; - log.error("[api/create-checkout-session] Stripe error", { - message: err.message, - type: err.type, - code: err.code, - }); - return NextResponse.json( - { - error: err.message ?? "Failed to create checkout session", - type: err.type, - }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/payment/customer-portal/route.ts b/src/app/api/payment/customer-portal/route.ts deleted file mode 100644 index 76925b28..00000000 --- a/src/app/api/payment/customer-portal/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * POST /api/payment/customer-portal - * - * 创建 Stripe Customer Portal Session → 返回 URL 给前端 - * - * 为什么需要 Customer Portal: - * - Stripe 托管的"用户自助管理"页(取消 / 换 plan / 查看账单 / 更新卡) - * - 不需要我们自己写 UI - * - 最常用的就是 "取消订阅"(避免客服) - * - * 需要的参数:`customerId`(从 User.stripeCustomerId 取)—— webhook 会设置 - * - 如果为 null:返错("先购买") - */ -import { NextRequest, NextResponse } from "next/server"; - -import { getStripeClient } from "@/lib/stripe/stripe-client"; -import { Logger } from "@/utils"; - -const log = new Logger("AppApiPaymentCustomerPortalRoute"); - -interface CreatePortalRequestBody { - customerId: string | null; - returnUrl: string; -} - -export async function POST(req: NextRequest) { - let body: CreatePortalRequestBody; - try { - body = (await req.json()) as CreatePortalRequestBody; - } catch { - return NextResponse.json( - { error: "Invalid JSON body" }, - { status: 400 }, - ); - } - - // 入参验证 - if (!body.customerId) { - return NextResponse.json( - { error: "Missing customerId. Please subscribe first before managing subscription." }, - { status: 400 }, - ); - } - if (!body.returnUrl) { - return NextResponse.json( - { error: "Missing returnUrl" }, - { status: 400 }, - ); - } - - // 调 Stripe API 创建 Customer Portal Session - const stripe = getStripeClient(); - try { - const session = await stripe.billingPortal.sessions.create({ - customer: body.customerId, - return_url: body.returnUrl, - }); - - return NextResponse.json({ url: session.url }); - } catch (e) { - const err = e as { message?: string; type?: string; code?: string }; - log.error("[api/customer-portal] Stripe error", { - customerId: body.customerId, - message: err.message, - type: err.type, - code: err.code, - }); - return NextResponse.json( - { - error: err.message ?? "Failed to create customer portal session", - type: err.type, - }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/payment/webhook/route.ts b/src/app/api/payment/webhook/route.ts deleted file mode 100644 index 861da91d..00000000 --- a/src/app/api/payment/webhook/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * POST /api/payment/webhook - * - * 接收 Stripe webhook → 验签 → 分发到 handler - * - * 关键设计: - * - 必须用 raw body 验签(不能让 Next.js 解析 JSON,所以用 `request.text()` 取原文) - * - 校验 `stripe-signature` 头(用 `STRIPE_WEBHOOK_SECRET`) - * - `stripe.webhooks.constructEvent(rawBody, sigHeader, secret)` 会抛错(如果签名不匹配) - * - 分发到 `dispatchStripeEvent()` → 4 个 handler 之一 - * - * 配置 Stripe webhook 端点: - * - URL:https://cozsweet.com/api/payment/webhook - * - 监听的事件: - * - checkout.session.completed - * - customer.subscription.created - * - customer.subscription.updated - * - customer.subscription.deleted - * - invoice.payment_succeeded - * - * 本地开发用 Stripe CLI 转发: - * stripe listen --forward-to localhost:3000/api/payment/webhook - * 输出的 `whsec_xxx` → 写到 .env.local 的 `STRIPE_WEBHOOK_SECRET` - */ -import { NextRequest, NextResponse } from "next/server"; -import type Stripe from "stripe"; - -import { dispatchStripeEvent } from "@/lib/stripe/stripe-events"; -import { getStripeClient } from "@/lib/stripe/stripe-client"; -import { Logger } from "@/utils"; - -const log = new Logger("AppApiPaymentWebhookRoute"); - -export async function POST(req: NextRequest) { - const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; - if (!webhookSecret) { - log.error("[api/webhook] STRIPE_WEBHOOK_SECRET is not set"); - return NextResponse.json( - { error: "Webhook secret not configured" }, - { status: 500 }, - ); - } - - // 取 raw body(验签用 raw bytes,不能 JSON.parse 后再 stringify —— 会丢空格、字节顺序等) - const rawBody = await req.text(); - const sigHeader = req.headers.get("stripe-signature"); - if (!sigHeader) { - log.error("[api/webhook] Missing stripe-signature header"); - return NextResponse.json( - { error: "Missing stripe-signature header" }, - { status: 400 }, - ); - } - - // 校验签名(不匹配会抛错) - const stripe = getStripeClient(); - let event: Stripe.Event; - try { - event = stripe.webhooks.constructEvent(rawBody, sigHeader, webhookSecret); - } catch (e) { - const err = e as Error; - log.error("[api/webhook] Signature verification failed", { - message: err.message, - }); - return NextResponse.json( - { error: `Webhook signature verification failed: ${err.message}` }, - { status: 400 }, - ); - } - - // 分发到 handler - log.debug("[api/webhook] Received event", { - eventId: event.id, - type: event.type, - created: new Date(event.created * 1000).toISOString(), - }); - - try { - const handled = await dispatchStripeEvent(event); - if (!handled) { - log.debug(`[api/webhook] Ignored event type: ${event.type}`); - } - } catch (e) { - const err = e as Error; - log.error("[api/webhook] Handler error", { - eventId: event.id, - type: event.type, - message: err.message, - stack: err.stack, - }); - // 返 500 让 Stripe 重试(幂等保护:handler 自己用 event.id 去重) - return NextResponse.json( - { error: "Handler error" }, - { status: 500 }, - ); - } - - // 返 200 让 Stripe 标记为已接收(即使 handler 没做什么也返 200 —— 避免无谓重试) - return NextResponse.json({ received: true }); -} - -/** 关闭 body parser 验签 raw body 需要 raw text(不能被 Next.js 的 bodyParser 解析)*/ -export const dynamic = "force-dynamic"; // 关闭静态优化(避免 body 被缓存) diff --git a/src/app/subscription/cancel/page.tsx b/src/app/subscription/cancel/page.tsx index fc6e1b30..802cf980 100644 --- a/src/app/subscription/cancel/page.tsx +++ b/src/app/subscription/cancel/page.tsx @@ -3,8 +3,8 @@ * 订阅取消回跳页 * * 流程: - * 1. 用户在 Stripe 托管页点 "Cancel" / 关闭页面 - * 2. Stripe 重定向到 `/subscription/cancel` + * 1. 用户在支付服务托管页点 "Cancel" / 关闭页面 + * 2. 支付服务重定向到 `/subscription/cancel` * 3. 这个页面显示 "已取消" + 跳回 `/subscription` * * 不需要任何后端逻辑(没付款发生) diff --git a/src/app/subscription/components/index.ts b/src/app/subscription/components/index.ts index 35e8d6c3..a1da90f9 100644 --- a/src/app/subscription/components/index.ts +++ b/src/app/subscription/components/index.ts @@ -7,6 +7,5 @@ export * from "./subscription-banner"; export * from "./subscription-benefits-card"; export * from "./subscription-checkout-button"; export * from "./subscription-cta-button"; -export * from "./subscription-manage-button"; export * from "./subscription-plan-card"; export * from "./subscription-user-row"; diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 07370f90..5c33852c 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -2,75 +2,63 @@ /** * 订阅 checkout 触发按钮 * - * 包装 `SubscriptionCtaButton` —— 加上调 `/api/payment/create-checkout-session` 的逻辑 - * - * 流程: - * 1. 用户点 "Confirm the agreement and Activate" - * 2. fetch POST /api/payment/create-checkout-session { planId, successUrl, cancelUrl } - * 3. 拿到 `{ url }` → `window.location.href = url` 跳转到 Stripe 托管页 - * 4. 用户结账 → Stripe 回跳到 successUrl / cancelUrl - * 5. 同时发 webhook → 后端设置 user.isVip + voiceMinutes + * 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。 */ -import { useState } from "react"; +import { useEffect, useRef } from "react"; -import type { SubscriptionPlanId } from "@/data/constants/subscription-plans"; -import { ROUTES } from "@/router/routes"; +import { + usePaymentDispatch, + usePaymentState, +} from "@/stores/payment/payment-context"; import { SubscriptionCtaButton } from "./subscription-cta-button"; import styles from "./subscription-cta-button.module.css"; -import { Logger } from "@/utils"; - -const log = new Logger("AppSubscriptionComponentsSubscriptionCheckoutButton"); export interface SubscriptionCheckoutButtonProps { - planId: SubscriptionPlanId; /** 是否可用(未选套餐 / 未勾选协议 → false) */ disabled?: boolean; } export function SubscriptionCheckoutButton({ - planId, disabled = false, }: SubscriptionCheckoutButtonProps) { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const payment = usePaymentState(); + const paymentDispatch = usePaymentDispatch(); + const launchedNonceRef = useRef(0); - const handleClick = async () => { - if (disabled || isLoading) return; - setIsLoading(true); - setError(null); - - try { - // success / cancel URL —— 走当前 origin 的绝对路径 - const origin = window.location.origin; - const res = await fetch("/api/payment/create-checkout-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - planId, - successUrl: `${origin}${ROUTES.subscription}/success?session_id={CHECKOUT_SESSION_ID}`, - cancelUrl: `${origin}${ROUTES.subscription}/cancel`, - }), - }); - - if (!res.ok) { - const data = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(data.error ?? `HTTP ${res.status}`); - } - - const { url } = (await res.json()) as { url: string }; - if (!url) { - throw new Error("No checkout URL returned"); - } - - // 跳转到 Stripe 托管页 - window.location.href = url; - } catch (e) { - const err = e as Error; - log.error("[subscription-checkout-button] error", err); - setError(err.message); - setIsLoading(false); + useEffect(() => { + if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { + return; } + + launchedNonceRef.current = payment.launchNonce; + const paymentUrl = getPaymentUrl(payment.payParams); + if (!paymentUrl) { + paymentDispatch({ + type: "PaymentLaunchFailed", + errorMessage: "Payment parameters did not include a supported URL.", + }); + return; + } + + window.location.href = paymentUrl; + }, [payment.launchNonce, payment.payParams, paymentDispatch]); + + const isLoading = + payment.isCreatingOrder || + payment.isPollingOrder || + payment.isCheckingVipStatus; + const label = payment.isPaid + ? "Activated" + : payment.isPollingOrder + ? "Processing payment..." + : payment.isCreatingOrder + ? "Creating order..." + : "Confirm the agreement and Activate"; + + const handleClick = () => { + if (disabled || isLoading) return; + paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); }; return ( @@ -82,9 +70,9 @@ export function SubscriptionCheckoutButton({ onClick={handleClick} className={styles.button} > - Confirm the agreement and Activate + {label} - {error ? ( + {payment.errorMessage ? (

- {error} + {payment.errorMessage}

) : null} ); } + +function getPaymentUrl(payParams: Record): string | null { + const keys = [ + "url", + "checkoutUrl", + "checkout_url", + "paymentUrl", + "payment_url", + "redirectUrl", + "redirect_url", + ]; + + for (const key of keys) { + const value = payParams[key]; + if (typeof value === "string" && value.length > 0) return value; + } + + return null; +} diff --git a/src/app/subscription/components/subscription-manage-button.tsx b/src/app/subscription/components/subscription-manage-button.tsx deleted file mode 100644 index 8482fb44..00000000 --- a/src/app/subscription/components/subscription-manage-button.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; -/** - * "Manage VIP" 按钮组件(取消订阅 / 换 plan / 查看账单) - * - * 点击后调 `/api/payment/customer-portal` 拿到 Stripe Customer Portal 页面的 URL - * - * 业务事实: - * - 不自己写 "取消订阅" UI —— 跳到 Stripe 自己的 Customer Portal - * - 要 `customerId` —— webhook 会设置到 User.stripeCustomerId - * - 如果为 null(用户从未购买)→ 提示 "先购买"("Get VIP" 路径走过来就是这个) - */ -import { useState } from "react"; - -import { ROUTES } from "@/router/routes"; - -import { SubscriptionCtaButton } from "./subscription-cta-button"; -import styles from "./subscription-cta-button.module.css"; -import { Logger } from "@/utils"; - -const log = new Logger("AppSubscriptionComponentsSubscriptionManageButton"); - -export interface SubscriptionManageButtonProps { - /** 从 User.stripeCustomerId 取(webhook 设置) */ - customerId: string | null; -} - -export function SubscriptionManageButton({ customerId }: SubscriptionManageButtonProps) { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const handleClick = async () => { - if (isLoading) return; - - // 没有 customerId → 不该出现这个按钮(sidebar 根据 isVip + stripeCustomerId 控制展示) - // 防御性检查:如果走到这里没有 customerId,提示用户联系客服 - if (!customerId) { - setError("Please subscribe first before managing your subscription."); - return; - } - - setIsLoading(true); - setError(null); - - try { - const origin = window.location.origin; - const res = await fetch("/api/payment/customer-portal", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - customerId, - returnUrl: `${origin}${ROUTES.subscription}`, - }), - }); - - if (!res.ok) { - const data = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(data.error ?? `HTTP ${res.status}`); - } - - const { url } = (await res.json()) as { url: string }; - if (!url) { - throw new Error("No portal URL returned"); - } - - // 跳转到 Stripe Customer Portal - window.location.href = url; - } catch (e) { - const err = e as Error; - log.error("[subscription-manage-button] error", err); - setError(err.message); - setIsLoading(false); - } - }; - - return ( - <> - - Manage VIP - - {error ? ( -

- {error} -

- ) : null} - - ); -} diff --git a/src/app/subscription/components/subscription-plan-card.tsx b/src/app/subscription/components/subscription-plan-card.tsx index 136bba70..27c5cb7f 100644 --- a/src/app/subscription/components/subscription-plan-card.tsx +++ b/src/app/subscription/components/subscription-plan-card.tsx @@ -9,20 +9,31 @@ * - 选中/高亮:2px 粉色边框 + 浅粉阴影 * - 三个卡片底部渐变不同:粉 → 紫红 → 紫 */ -import type { SubscriptionPlan, SubscriptionPlanId } from "@/data/constants/subscription-plans"; import styles from "./subscription-plan-card.module.css"; -const PER_DAY_GRADIENTS: Record = { - monthly: "linear-gradient(90deg, #ff7ab8 0%, #ff5d9c 100%)", - quarterly: "linear-gradient(90deg, #d16bff 0%, #ff5da3 100%)", - annual: "linear-gradient(90deg, #9a4dff 0%, #c44dff 100%)", -}; +const PER_DAY_GRADIENTS = [ + "linear-gradient(90deg, #ff7ab8 0%, #ff5d9c 100%)", + "linear-gradient(90deg, #d16bff 0%, #ff5da3 100%)", + "linear-gradient(90deg, #9a4dff 0%, #c44dff 100%)", + "linear-gradient(90deg, #32c7b5 0%, #2f8fd8 100%)", +] as const; + +export interface SubscriptionPlanView { + id: string; + name: string; + price: string; + originalPrice: string | null; + perDay: string; + highlight: boolean; + currencySymbol: string; +} export interface SubscriptionPlanCardProps { - plan: SubscriptionPlan; + plan: SubscriptionPlanView; selected: boolean; onClick: () => void; + gradientIndex?: number; className?: string; } @@ -30,6 +41,7 @@ export function SubscriptionPlanCard({ plan, selected, onClick, + gradientIndex = 0, className, }: SubscriptionPlanCardProps) { const classes = [ @@ -41,22 +53,26 @@ export function SubscriptionPlanCard({ .filter(Boolean) .join(" "); - const perDayStyle = { background: PER_DAY_GRADIENTS[plan.id] }; + const perDayStyle = { + background: PER_DAY_GRADIENTS[gradientIndex % PER_DAY_GRADIENTS.length], + }; return (