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:
2026-06-16 10:21:02 +08:00
parent 17741320ff
commit 0548a08cbb
25 changed files with 1220 additions and 26 deletions
+55
View File
@@ -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}>
+142
View File
@@ -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>
);
}