feat: integrate Stripe payment with subscription plans
Add Stripe payment integration across the project: - Add @stripe/stripe-js dependency - Configure Stripe environment variables (secret key, publishable key, webhook secret) for dev, local, and production environments - Add Product/Price IDs for monthly, quarterly, and annual subscription tiers - Extend subscription plan data with voiceMinutesPerDay quotas (30/45/60 minutes per tier) - Update .gitignore to exclude implementation_plan.md
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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 };
|
||||
console.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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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 };
|
||||
console.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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
console.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) {
|
||||
console.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;
|
||||
console.error("[api/webhook] Signature verification failed", {
|
||||
message: err.message,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: `Webhook signature verification failed: ${err.message}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 分发到 handler
|
||||
console.log("[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) {
|
||||
console.log(`[api/webhook] Ignored event type: ${event.type}`);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
console.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 被缓存)
|
||||
@@ -44,7 +44,7 @@ export function SidebarScreen() {
|
||||
|
||||
// 等价 Dart: BlocConsumer<UserBloc> 的 listener(profile_view.dart:24-34)
|
||||
// 仅在 currentUser 由非 null 跳到 null 时触发清理
|
||||
// 注:chat 机器**不**再需要 `ChatAuthStatusChanged` 通知(已**删**)—— chat-screen
|
||||
// 注:chat 机器不再需要 `ChatAuthStatusChanged` 通知(已删)—— chat-screen
|
||||
// 通过 `useAuthState()` 主动订阅 loginStatus;这里只需重置 auth + 跳 /chat
|
||||
const prevUserRef = useRef<UserView | null>(user.currentUser);
|
||||
useEffect(() => {
|
||||
@@ -111,6 +111,9 @@ export function SidebarScreen() {
|
||||
user={user.currentUser}
|
||||
coinBalance={user.coinBalance}
|
||||
isVip={sidebarState === "vip"}
|
||||
voiceMinutesRemaining={user.currentUser.voiceMinutesRemaining}
|
||||
|
||||
|
||||
/>
|
||||
<VipCta variant={sidebarState === "vip" ? "manage" : "get"} />
|
||||
</>
|
||||
|
||||
@@ -10,14 +10,21 @@ export interface UserInfoCardProps {
|
||||
user: UserView;
|
||||
coinBalance: number;
|
||||
isVip: boolean;
|
||||
voiceMinutesRemaining: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已登录用户信息卡片
|
||||
*
|
||||
* 头像 + 用户名(+ VIP 徽章)+ 邮箱 + 金币余额。
|
||||
* 头像 + 用户名(+ VIP 徽章)+ 邮箱 + 金币余额 + 语音剩余分钟。
|
||||
*/
|
||||
export function UserInfoCard({ user, coinBalance, isVip }: UserInfoCardProps) {
|
||||
export function UserInfoCard({
|
||||
user,
|
||||
coinBalance,
|
||||
isVip,
|
||||
voiceMinutesRemaining,
|
||||
}: UserInfoCardProps) {
|
||||
|
||||
const initial = (user.username?.[0] ?? "?").toUpperCase();
|
||||
const hasAvatar = user.avatarUrl && user.avatarUrl.length > 0;
|
||||
|
||||
@@ -75,7 +82,28 @@ export function UserInfoCard({ user, coinBalance, isVip }: UserInfoCardProps) {
|
||||
</svg>
|
||||
<span>{coinBalance} coins</span>
|
||||
</div>
|
||||
{isVip ? (
|
||||
<div className={styles.userInfoVoiceRow} aria-label="Voice minutes">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z M19 10v2a7 7 0 01-14 0v-2 M12 19v4 M8 23h8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{voiceMinutesRemaining} voice min</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
/**
|
||||
* 订阅取消回跳页
|
||||
*
|
||||
* 流程:
|
||||
* 1. 用户在 Stripe 托管页点 "Cancel" / 关闭页面
|
||||
* 2. Stripe 重定向到 `/subscription/cancel`
|
||||
* 3. 这个页面显示 "已取消" + 跳回 `/subscription`
|
||||
*
|
||||
* 不需要任何后端逻辑(没付款发生)
|
||||
*/
|
||||
import Link from "next/link";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
export default function SubscriptionCancelPage() {
|
||||
return (
|
||||
<MobileShell>
|
||||
<div
|
||||
style={{
|
||||
padding: "2rem 1.5rem",
|
||||
textAlign: "center",
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, color: "#333" }}>
|
||||
Checkout cancelled
|
||||
</h1>
|
||||
<p style={{ color: "#666" }}>
|
||||
You did not complete the subscription. No payment was charged.
|
||||
</p>
|
||||
<Link
|
||||
href={ROUTES.subscription}
|
||||
style={{
|
||||
padding: "0.75rem 1.5rem",
|
||||
borderRadius: "12px",
|
||||
background: "linear-gradient(135deg, #f96ADE, #f657A0)",
|
||||
color: "white",
|
||||
textDecoration: "none",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</Link>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
/**
|
||||
* 订阅 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
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import type { SubscriptionPlanId } from "@/data/constants/subscription-plans";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||
import styles from "./subscription-cta-button.module.css";
|
||||
|
||||
export interface SubscriptionCheckoutButtonProps {
|
||||
planId: SubscriptionPlanId;
|
||||
/** 是否可用(未选套餐 / 未勾选协议 → false) */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SubscriptionCheckoutButton({
|
||||
planId,
|
||||
disabled = false,
|
||||
}: SubscriptionCheckoutButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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;
|
||||
console.error("[subscription-checkout-button] error", err);
|
||||
setError(err.message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubscriptionCtaButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
isLoading={isLoading}
|
||||
onClick={handleClick}
|
||||
className={styles.button}
|
||||
>
|
||||
Confirm the agreement and Activate
|
||||
</SubscriptionCtaButton>
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
style={{
|
||||
color: "#c0392b",
|
||||
fontSize: "0.875rem",
|
||||
marginTop: "0.5rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"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";
|
||||
|
||||
export interface SubscriptionManageButtonProps {
|
||||
/** 从 User.stripeCustomerId 取(webhook 设置) */
|
||||
customerId: string | null;
|
||||
}
|
||||
|
||||
export function SubscriptionManageButton({ customerId }: SubscriptionManageButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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;
|
||||
console.error("[subscription-manage-button] error", err);
|
||||
setError(err.message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubscriptionCtaButton
|
||||
type="button"
|
||||
disabled={!customerId}
|
||||
isLoading={isLoading}
|
||||
onClick={handleClick}
|
||||
className={styles.button}
|
||||
>
|
||||
Manage VIP
|
||||
</SubscriptionCtaButton>
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
style={{
|
||||
color: "#c0392b",
|
||||
fontSize: "0.875rem",
|
||||
marginTop: "0.5rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,19 +15,21 @@
|
||||
* 8. 协议复选框
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Checkbox } from "@/app/_components/core/checkbox";
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { AppConstants } from "@/core/app_constants";
|
||||
import { SUBSCRIPTION_PLANS } from "@/data/constants/subscription-plans";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import {
|
||||
SUBSCRIPTION_PLANS,
|
||||
type SubscriptionPlanId,
|
||||
} from "@/data/constants/subscription-plans";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import { SubscriptionBackLink } from "./subscription-back-link";
|
||||
import { SubscriptionBanner } from "./subscription-banner";
|
||||
import { SubscriptionBenefitsCard } from "./subscription-benefits-card";
|
||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||
import { SubscriptionCheckoutButton } from "./subscription-checkout-button";
|
||||
import { SubscriptionManageButton } from "./subscription-manage-button";
|
||||
import { SubscriptionPlanCard } from "./subscription-plan-card";
|
||||
import { SubscriptionUserRow } from "./subscription-user-row";
|
||||
import styles from "./subscription-screen.module.css";
|
||||
@@ -42,25 +44,20 @@ const VIP_BENEFITS = [
|
||||
const FALLBACK_USERNAME = "User name";
|
||||
|
||||
export function SubscriptionScreen() {
|
||||
const router = useRouter();
|
||||
const user = useUserState();
|
||||
|
||||
const initialPlanId =
|
||||
SUBSCRIPTION_PLANS.find((p) => p.highlight)?.id ?? null;
|
||||
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(
|
||||
initialPlanId,
|
||||
// 强制类型为 `SubscriptionPlanId` —— 套餐表只有 3 个 id,不会越界
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<SubscriptionPlanId | null>(
|
||||
initialPlanId as SubscriptionPlanId | null,
|
||||
);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
|
||||
const canActivate = selectedPlanId !== null && agreed;
|
||||
|
||||
const handleActivate = () => {
|
||||
if (!canActivate) return;
|
||||
// Mock phase: 仅提示并跳走,不调用任何支付接口
|
||||
window.alert("Subscription activated (mock). Welcome to VIP!");
|
||||
router.push(ROUTES.chat);
|
||||
};
|
||||
const isVip = user.currentUser?.isVip ?? false;
|
||||
const stripeCustomerId = user.currentUser?.stripeCustomerId ?? null;
|
||||
|
||||
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
||||
const avatarUrl = user.currentUser?.avatarUrl ?? null;
|
||||
@@ -107,12 +104,18 @@ export function SubscriptionScreen() {
|
||||
</section>
|
||||
|
||||
<div className={styles.ctaSlot}>
|
||||
<SubscriptionCtaButton
|
||||
disabled={!canActivate}
|
||||
onClick={handleActivate}
|
||||
>
|
||||
Confirm the agreement and Activate
|
||||
</SubscriptionCtaButton>
|
||||
{/*
|
||||
* 已为 VIP 的用户 → 显示 Manage VIP 按钮(跳转到 Stripe Customer Portal)
|
||||
* VIP 状态从 `user.isVip` 取(webhook 会设置)
|
||||
*/}
|
||||
{isVip ? (
|
||||
<SubscriptionManageButton customerId={stripeCustomerId} />
|
||||
) : (
|
||||
<SubscriptionCheckoutButton
|
||||
planId={selectedPlanId ?? "monthly"}
|
||||
disabled={!canActivate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.agreementSlot}>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
/**
|
||||
* 订阅成功回跳页
|
||||
*
|
||||
* 流程:
|
||||
* 1. 用户在 Stripe 托管页完成结账
|
||||
* 2. Stripe 重定向到 `/subscription/success?session_id=xxx`
|
||||
* 3. 这个页面显示 "购买成功" + 调 `getCurrentUser` 刷新 user
|
||||
* 4. 点 "返回订阅页" 跳回 `/subscription`
|
||||
*
|
||||
* 注意:webhook 会异步触发 + 更新 user.isVip + voiceMinutes
|
||||
* 此页面拿到 session_id 后 轮询几次 getCurrentUser 等待 webhook 完成(避免 webhook 还没到 user 就还没更新的情况)
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { authRepository } from "@/data/repositories/auth_repository";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
export default function SubscriptionSuccessPage() {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<"polling" | "ok" | "error">("polling");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const hasRefreshed = useRef(false);
|
||||
|
||||
// 轮询 getCurrentUser 等待 webhook 完成
|
||||
useEffect(() => {
|
||||
if (hasRefreshed.current) return;
|
||||
hasRefreshed.current = true;
|
||||
|
||||
let cancelled = false;
|
||||
const tryRefresh = async (attemptsLeft: number) => {
|
||||
if (cancelled) return;
|
||||
const r = await authRepository.getCurrentUser();
|
||||
if (cancelled) return;
|
||||
if (r.success) {
|
||||
if (r.data.isVip) {
|
||||
setStatus("ok");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setErrorMessage(r.error?.message ?? "Unknown error");
|
||||
}
|
||||
if (attemptsLeft > 0) {
|
||||
setTimeout(() => void tryRefresh(attemptsLeft - 1), 2000);
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
void tryRefresh(5);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MobileShell>
|
||||
<div
|
||||
style={{
|
||||
padding: "2rem 1.5rem",
|
||||
textAlign: "center",
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{status === "polling" ? (
|
||||
<>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 600 }}>
|
||||
Processing your subscription…
|
||||
</h1>
|
||||
<p style={{ color: "#666" }}>
|
||||
Your payment was received. We are confirming the details with
|
||||
our server.
|
||||
</p>
|
||||
</>
|
||||
) : status === "ok" ? (
|
||||
<>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, color: "#1f8b3a" }}>
|
||||
🎉 Subscription activated!
|
||||
</h1>
|
||||
<p style={{ color: "#666" }}>
|
||||
Welcome to VIP. Enjoy unlimited chat, free voice minutes, and
|
||||
more.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(ROUTES.chat)}
|
||||
style={{
|
||||
padding: "0.75rem 1.5rem",
|
||||
borderRadius: "12px",
|
||||
background: "linear-gradient(135deg, #f96ADE, #f657A0)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Start chatting
|
||||
</button>
|
||||
<Link
|
||||
href={ROUTES.subscription}
|
||||
style={{ color: "#888", fontSize: "0.875rem" }}
|
||||
>
|
||||
Back to subscription
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, color: "#c0392b" }}>
|
||||
Subscription status unclear
|
||||
</h1>
|
||||
<p style={{ color: "#666" }}>
|
||||
We received your payment but could not confirm your VIP
|
||||
status. {errorMessage ?? "Please refresh in a few seconds."}
|
||||
</p>
|
||||
<Link
|
||||
href={ROUTES.subscription}
|
||||
style={{
|
||||
padding: "0.75rem 1.5rem",
|
||||
borderRadius: "12px",
|
||||
background: "#f0f0f0",
|
||||
color: "#333",
|
||||
textDecoration: "none",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Back to subscription
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user