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
+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>
);
}