feat(subscription): support Stripe payment element

This commit is contained in:
2026-06-18 18:03:36 +08:00
parent 8cca42238e
commit 5bf98e9452
16 changed files with 402 additions and 76 deletions
+2
View File
@@ -10,6 +10,8 @@ NEXT_PUBLIC_API_BASE_URL=http://172.16.48.49:3002
# WebSocket URL(开发环境,可选)
NEXT_PUBLIC_WS_BASE_URL=ws://172.16.48.49:3002/ws
# Stripe 支付(publishable key 可暴露在浏览器端)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
# API 超时时间(毫秒)
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
+1
View File
@@ -5,6 +5,7 @@ NEXTAUTH_URL=https://frontend-test.banlv-ai.com
NEXTAUTH_URL_INTERNAL=http://localhost:3000
NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
NEXT_PUBLIC_API_RECEIVE_TIMEOUT=60000
+2
View File
@@ -11,6 +11,8 @@ NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
# WebSocket URL(生产环境)
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
# Stripe 支付(publishable key 可暴露在浏览器端)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
# API 超时时间(毫秒)
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
+2
View File
@@ -19,6 +19,8 @@
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@stripe/react-stripe-js": "^6.6.0",
"@stripe/stripe-js": "^9.8.0",
"@xstate/react": "^5",
"classnames": "^2.5.1",
"dexie": "^4.4.3",
+26
View File
@@ -11,6 +11,12 @@ importers:
'@fingerprintjs/fingerprintjs':
specifier: ^5.2.0
version: 5.2.0
'@stripe/react-stripe-js':
specifier: ^6.6.0
version: 6.6.0(@stripe/stripe-js@9.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@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)
@@ -890,6 +896,17 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@stripe/react-stripe-js@6.6.0':
resolution: {integrity: sha512-utODPiu2/JGjCnh5BX1M1F2uyjCwDKum4Bo8CeWdTCNOlzM0980YadzBMe7YoIwjfu3uadX4PMe3L2SderejqA==}
peerDependencies:
'@stripe/stripe-js': '>=9.5.0 <10.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@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'}
@@ -3970,6 +3987,15 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@stripe/react-stripe-js@6.6.0(@stripe/stripe-js@9.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@stripe/stripe-js': 9.8.0
prop-types: 15.8.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@stripe/stripe-js@9.8.0': {}
'@swc/core-darwin-arm64@1.15.32':
optional: true
@@ -6,24 +6,41 @@ import styles from "./message-avatar.module.css";
export interface UserMessageAvatarProps {
avatarUrl?: string | null;
className?: string;
size?: number;
}
export function UserMessageAvatar({ avatarUrl }: UserMessageAvatarProps) {
export function UserMessageAvatar({
avatarUrl,
className,
size = 43,
}: UserMessageAvatarProps) {
const avatarClassName = [styles.avatar, className].filter(Boolean).join(" ");
const avatarStyle = { width: size, height: size };
if (avatarUrl && avatarUrl.length > 0) {
return (
<div className={styles.avatar} aria-label="User avatar">
<Image src={avatarUrl} alt="" width={43} height={43} />
<div
className={avatarClassName}
style={avatarStyle}
aria-label="User avatar"
>
<Image src={avatarUrl} alt="" width={size} height={size} />
</div>
);
}
return (
<div className={styles.avatar} aria-label="Guest avatar">
<div
className={avatarClassName}
style={avatarStyle}
aria-label="Guest avatar"
>
<Image
src="/images/chat/pic-chat-guest.png"
alt="Guest"
width={43}
height={43}
width={size}
height={size}
/>
</div>
);
+6 -26
View File
@@ -1,6 +1,6 @@
"use client";
import Image from "next/image";
import { UserMessageAvatar } from "@/app/chat/components/user-message-avatar";
import styles from "./user-header.module.css";
@@ -49,31 +49,11 @@ export function UserHeader({
return (
<div className={styles.row}>
<div className={styles.avatar} aria-hidden="true">
{avatarUrl ? (
<Image
src={avatarUrl}
alt=""
width={56}
height={56}
className={styles.avatarImg}
/>
) : (
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
className={styles.avatarIcon}
>
<circle cx="12" cy="8" r="4" fill="currentColor" />
<path
d="M4 20c0-4.418 3.582-7 8-7s8 2.582 8 7"
fill="currentColor"
/>
</svg>
)}
</div>
<UserMessageAvatar
avatarUrl={avatarUrl}
className={styles.avatar}
size={56}
/>
<div className={styles.text}>
<p className={styles.name}>{name}</p>
+1
View File
@@ -9,3 +9,4 @@ export * from "./subscription-checkout-button";
export * from "./subscription-cta-button";
export * from "./subscription-plan-card";
export * from "./subscription-user-row";
export * from "./stripe-payment-dialog";
@@ -0,0 +1,88 @@
.overlay {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.5);
}
.dialog {
width: 100%;
max-width: 420px;
max-height: calc(100vh - 40px);
overflow-y: auto;
border-radius: 32px;
background: var(--color-page-background, #ffffff);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
padding: 24px 18px 18px;
}
.header {
margin-bottom: 18px;
text-align: left;
}
.title {
margin: 0 0 8px;
color: var(--color-text-foreground, #171717);
font-size: var(--font-size-22, 22px);
font-weight: 700;
line-height: 1.2;
}
.content {
margin: 0;
color: #393939;
font-size: var(--font-size-md, 14px);
line-height: 1.5;
}
.form {
display: flex;
flex-direction: column;
gap: 18px;
}
.error {
margin: 0;
color: #c0392b;
font-size: var(--font-size-sm, 13px);
line-height: 1.45;
}
.actions {
display: flex;
gap: var(--spacing-md, 12px);
width: 100%;
}
.button {
flex: 1 1 auto;
min-height: var(--pwa-button-height, 44px);
border: 0;
border-radius: var(--radius-bottom-sheet, 28px);
font-size: var(--font-size-lg, 16px);
font-weight: 600;
cursor: pointer;
}
.button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.secondary {
background: var(--color-text-secondary, #9e9e9e);
color: var(--color-page-background, #ffffff);
}
.primary {
background: linear-gradient(
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
color: var(--color-page-background, #ffffff);
}
@@ -0,0 +1,169 @@
"use client";
/**
* Stripe Payment Element 弹窗
*
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
*/
import { useState, type FormEvent } from "react";
import {
Elements,
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { ROUTES } from "@/router/routes";
import styles from "./stripe-payment-dialog.module.css";
const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
const stripePromise = stripePublishableKey
? loadStripe(stripePublishableKey)
: null;
export interface StripePaymentDialogProps {
clientSecret: string;
onClose: () => void;
onConfirmed?: () => void;
}
export function StripePaymentDialog({
clientSecret,
onClose,
onConfirmed,
}: StripePaymentDialogProps) {
if (!stripePromise) {
return (
<div className={styles.overlay} role="alertdialog" aria-modal="true">
<div className={styles.dialog}>
<div className={styles.header}>
<h2 className={styles.title}>Payment unavailable</h2>
<p className={styles.content}>
Stripe publishable key is not configured for this build.
</p>
</div>
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.primary}`}
onClick={onClose}
>
OK
</button>
</div>
</div>
</div>
);
}
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="stripe-payment-title"
>
<div className={styles.dialog}>
<div className={styles.header}>
<h2 id="stripe-payment-title" className={styles.title}>
Complete payment
</h2>
<p className={styles.content}>
Enter your payment details securely through Stripe.
</p>
</div>
<Elements
key={clientSecret}
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: "stripe",
variables: {
borderRadius: "14px",
colorPrimary: "#ff52a2",
colorText: "#171717",
},
},
}}
>
<StripePaymentForm onClose={onClose} onConfirmed={onConfirmed} />
</Elements>
</div>
</div>
);
}
function StripePaymentForm({
onClose,
onConfirmed,
}: Pick<StripePaymentDialogProps, "onClose" | "onConfirmed">) {
const stripe = useStripe();
const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!stripe || !elements || isSubmitting) return;
setIsSubmitting(true);
setErrorMessage(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setErrorMessage(submitError.message ?? "Please check your payment info.");
setIsSubmitting(false);
return;
}
const returnUrl = new URL(
ROUTES.subscription + "/success",
window.location.origin,
);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: "if_required",
});
if (error) {
setErrorMessage(error.message ?? "Payment confirmation failed.");
setIsSubmitting(false);
return;
}
onConfirmed?.();
};
return (
<form className={styles.form} onSubmit={handleSubmit}>
<PaymentElement />
{errorMessage ? (
<p role="alert" className={styles.error}>
{errorMessage}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.secondary}`}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className={`${styles.button} ${styles.primary}`}
disabled={!stripe || !elements || isSubmitting}
>
{isSubmitting ? "Processing..." : "Pay now"}
</button>
</div>
</form>
);
}
@@ -4,13 +4,14 @@
*
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
*/
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import {
usePaymentDispatch,
usePaymentState,
} from "@/stores/payment/payment-context";
import { StripePaymentDialog } from "./stripe-payment-dialog";
import { SubscriptionCtaButton } from "./subscription-cta-button";
import styles from "./subscription-cta-button.module.css";
@@ -25,6 +26,9 @@ export function SubscriptionCheckoutButton({
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null
>(null);
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
@@ -33,15 +37,21 @@ export function SubscriptionCheckoutButton({
launchedNonceRef.current = payment.launchNonce;
const paymentUrl = getPaymentUrl(payment.payParams);
if (!paymentUrl) {
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: "Payment parameters did not include a supported URL.",
});
if (paymentUrl) {
window.location.href = paymentUrl;
return;
}
window.location.href = paymentUrl;
const clientSecret = getStripeClientSecret(payment.payParams);
if (clientSecret) {
return;
}
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage:
"Payment parameters did not include a supported URL or Stripe client secret.",
});
}, [payment.launchNonce, payment.payParams, paymentDispatch]);
const isLoading =
@@ -58,9 +68,33 @@ export function SubscriptionCheckoutButton({
const handleClick = () => {
if (disabled || isLoading) return;
setHiddenStripeClientSecret(null);
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
};
const handleStripeClose = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
if (payment.orderStatus !== "paid") {
paymentDispatch({ type: "PaymentReset" });
}
};
const handleStripeConfirmed = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
};
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const shouldShowStripeDialog =
stripeClientSecret !== null &&
stripeClientSecret !== hiddenStripeClientSecret &&
!payment.isPaid;
return (
<>
<SubscriptionCtaButton
@@ -85,6 +119,13 @@ export function SubscriptionCheckoutButton({
{payment.errorMessage}
</p>
) : null}
{shouldShowStripeDialog ? (
<StripePaymentDialog
clientSecret={stripeClientSecret}
onClose={handleStripeClose}
onConfirmed={handleStripeConfirmed}
/>
) : null}
</>
);
}
@@ -107,3 +148,22 @@ function getPaymentUrl(payParams: Record<string, unknown>): string | null {
return null;
}
function getStripeClientSecret(
payParams: Record<string, unknown>,
): string | null {
const provider = payParams.provider;
const clientSecret = payParams.clientSecret ?? payParams.client_secret;
const isStripeProvider =
typeof provider !== "string" || provider.toLowerCase() === "stripe";
if (
isStripeProvider &&
typeof clientSecret === "string" &&
clientSecret.length > 0
) {
return clientSecret;
}
return null;
}
@@ -26,11 +26,6 @@
transform: scale(0.98);
}
.highlight {
border-color: var(--color-accent);
box-shadow: 0 4px 12px rgba(248, 77, 150, 0.18);
}
.selected {
border-color: var(--color-accent);
border-width: 2px;
@@ -6,7 +6,7 @@
* - 12px / secondary
* - 28px / 300+ 线12px / line-through / 999
* - 28px 12px/600
* - /2px +
* - 2px +
* -
*/
@@ -25,7 +25,6 @@ export interface SubscriptionPlanView {
price: string;
originalPrice: string | null;
perDay: string;
highlight: boolean;
currencySymbol: string;
}
@@ -47,7 +46,6 @@ export function SubscriptionPlanCard({
const classes = [
styles.card,
selected ? styles.selected : "",
!selected && plan.highlight ? styles.highlight : "",
className,
]
.filter(Boolean)
@@ -7,7 +7,7 @@
* - 16px/60012px/secondary
* -
*/
import Image from "next/image";
import { UserMessageAvatar } from "@/app/chat/components/user-message-avatar";
import styles from "./subscription-user-row.module.css";
@@ -27,31 +27,11 @@ export function SubscriptionUserRow({
}: SubscriptionUserRowProps) {
return (
<div className={[styles.row, className].filter(Boolean).join(" ")}>
<div className={styles.avatar} aria-hidden="true">
{avatarUrl ? (
<Image
src={avatarUrl}
alt=""
width={56}
height={56}
className={styles.avatarImg}
/>
) : (
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
className={styles.avatarIcon}
>
<circle cx="12" cy="8" r="4" fill="currentColor" />
<path
d="M4 20c0-4.418 3.582-7 8-7s8 2.582 8 7"
fill="currentColor"
/>
</svg>
)}
</div>
<UserMessageAvatar
avatarUrl={avatarUrl}
className={styles.avatar}
size={56}
/>
<div className={styles.text}>
<p className={styles.name}>{name}</p>
<p className={styles.subtitle}>{subtitle}</p>
+2 -3
View File
@@ -60,14 +60,13 @@ function planCaption(plan: PaymentPlan): string {
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
}
function toPlanView(plan: PaymentPlan, index: number): SubscriptionPlanView {
function toPlanView(plan: PaymentPlan): SubscriptionPlanView {
return {
id: plan.planId,
name: plan.planName,
price: formatAmount(plan.amountCents),
originalPrice: null,
perDay: planCaption(plan),
highlight: plan.planId === "vip_monthly" || index === 0,
currencySymbol: currencySymbol(plan.currency),
};
}
@@ -96,7 +95,7 @@ export function SubscriptionScreen() {
() =>
payment.plans
.filter(isVipPlan)
.map((plan, index) => toPlanView(plan, index)),
.map((plan) => toPlanView(plan)),
[payment.plans],
);
+6
View File
@@ -209,6 +209,12 @@ export const paymentMachine = setup({
})),
},
},
on: {
PaymentReset: {
target: "ready",
actions: assign(resetOrderState),
},
},
},
waitingForPayment: {