"use client"; /** * Stripe Payment Element 弹窗 * * 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。 */ import { useEffect, useId, useRef, useState, type FormEvent } from "react"; import { Elements, ExpressCheckoutElement, PaymentElement, useElements, useStripe, } from "@stripe/react-stripe-js"; import { loadStripe, type AvailablePaymentMethods, type StripeExpressCheckoutElementConfirmEvent, } from "@stripe/stripe-js"; import { ModalPortal } from "@/app/_components/core/modal-portal"; import { ExceptionHandler } from "@/core/errors"; import { ROUTES } from "@/router/routes"; import { Logger } from "@/utils/logger"; import { BrowserDetector } from "@/utils/browser-detect"; import { PlatformDetector } from "@/utils/platform-detect"; import { getStripeCustomerSessionClientSecret } from "@/lib/payment/payment_launch"; import { behaviorAnalytics } from "@/lib/analytics"; import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles"; const log = new Logger("SubscriptionStripePaymentDialog"); const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null; type ExpressAvailabilityValue = boolean | { available: boolean } | undefined; function isExpressMethodAvailable(value: ExpressAvailabilityValue): boolean { return value === true || (typeof value === "object" && value.available); } export interface StripePaymentDialogProps { clientSecret: string; customerSessionClientSecret?: string | null; savedPaymentMethodsEnabled?: boolean; returnPath?: string; onClose: () => void; onConfirmed?: () => void; } export function StripePaymentDialog({ clientSecret, customerSessionClientSecret = null, savedPaymentMethodsEnabled = false, returnPath = ROUTES.subscription + "/success", onClose, onConfirmed, }: StripePaymentDialogProps) { const titleId = useId(); const descriptionId = useId(); const savedCardClientSecret = getStripeCustomerSessionClientSecret({ provider: "stripe", customerSessionClientSecret, savedPaymentMethodsEnabled, }); if (!stripePromise) { return ( Payment unavailable Stripe publishable key is not configured for this build. OK ); } return ( Complete payment Enter your payment details securely through Stripe. ); } function StripePaymentForm({ returnPath, onClose, onConfirmed, }: Pick< StripePaymentDialogProps, "returnPath" | "onClose" | "onConfirmed" >) { const stripe = useStripe(); const elements = useElements(); const [errorMessage, setErrorMessage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const submittingRef = useRef(false); const [hasExpressPaymentMethods, setHasExpressPaymentMethods] = useState< boolean | null >(null); const [expressMaxColumns, setExpressMaxColumns] = useState(1); useEffect(() => { if (typeof window.matchMedia !== "function") return; const query = window.matchMedia("(min-width: 640px)"); const updateColumns = () => setExpressMaxColumns(query.matches ? 2 : 1); updateColumns(); query.addEventListener("change", updateColumns); return () => query.removeEventListener("change", updateColumns); }, []); const handleLoadError = (event: Parameters< NonNullable["onLoadError"]> >[0]) => { log.error("[stripe-payment-dialog] PaymentElement load failed", { type: event.error.type, code: event.error.code, message: event.error.message, declineCode: event.error.decline_code, }); setErrorMessage( ExceptionHandler.message( event.error, "Payment methods could not be loaded. Please try again later.", ), ); }; const updateExpressAvailability = ( availablePaymentMethods: | AvailablePaymentMethods | { applePay?: { available: boolean }; googlePay?: { available: boolean }; link?: { available: boolean }; paypal?: { available: boolean }; amazonPay?: { available: boolean }; klarna?: { available: boolean }; } | undefined, ) => { const availability = { applePay: isExpressMethodAvailable(availablePaymentMethods?.applePay), googlePay: isExpressMethodAvailable(availablePaymentMethods?.googlePay), link: isExpressMethodAvailable(availablePaymentMethods?.link), paypal: isExpressMethodAvailable(availablePaymentMethods?.paypal), amazonPay: isExpressMethodAvailable(availablePaymentMethods?.amazonPay), klarna: isExpressMethodAvailable(availablePaymentMethods?.klarna), }; setHasExpressPaymentMethods(Object.values(availability).some(Boolean)); const browser = BrowserDetector.isFacebookInAppBrowser() ? "facebook" : BrowserDetector.getBrowserName() || "unknown"; const platform = PlatformDetector.getPlatform(); const metadata = { browser, platform, availablePaymentMethods: availability, }; log.info( "[stripe-payment-dialog] Express Checkout availability", metadata, ); behaviorAnalytics.elementClick( "stripe.express_checkout_availability", "Stripe Express Checkout availability", metadata, ); }; const handleExpressLoadError = (event: Parameters< NonNullable< React.ComponentProps["onLoadError"] > >[0]) => { setHasExpressPaymentMethods(false); log.warn("[stripe-payment-dialog] Express Checkout load failed", { type: event.error.type, code: event.error.code, message: event.error.message, }); }; const confirmPayment = async ( options: { submitPaymentElement: boolean; expressEvent?: StripeExpressCheckoutElementConfirmEvent; }, ) => { if (!stripe || !elements || submittingRef.current) return; submittingRef.current = true; setIsSubmitting(true); setErrorMessage(null); if (options.submitPaymentElement) { let submitError: unknown; try { ({ error: submitError } = await elements.submit()); } catch (error) { submitError = error; } if (submitError) { setErrorMessage( ExceptionHandler.message( submitError, "Please check your payment info.", ), ); submittingRef.current = false; setIsSubmitting(false); return; } } const returnUrl = new URL( returnPath ?? ROUTES.subscription + "/success", window.location.origin, ); let confirmationError: unknown; try { ({ error: confirmationError } = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl.toString(), }, redirect: "if_required", })); } catch (error) { confirmationError = error; } if (confirmationError) { const message = ExceptionHandler.message( confirmationError, "Payment confirmation failed.", ); setErrorMessage(message); options.expressEvent?.paymentFailed({ reason: "fail", message }); submittingRef.current = false; setIsSubmitting(false); return; } onConfirmed?.(); }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); await confirmPayment({ submitPaymentElement: true }); }; return ( updateExpressAvailability(event.availablePaymentMethods) } onAvailablePaymentMethodsChange={(event) => updateExpressAvailability(event.paymentMethods) } onLoadError={handleExpressLoadError} onConfirm={(event) => void confirmPayment({ submitPaymentElement: false, expressEvent: event, }) } /> Pay by card {errorMessage ? ( {errorMessage} ) : null} Cancel {isSubmitting ? "Processing..." : "Pay now"} ); }
Stripe publishable key is not configured for this build.
Enter your payment details securely through Stripe.
{errorMessage}