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
@@ -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;
}