"use client"; /** * 订阅 checkout 触发按钮 * * 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。 */ import { useEffect, useRef, useState } from "react"; import { PendingPaymentOrderStorage } from "@/data/storage"; import { usePaymentDispatch, usePaymentState, } from "@/stores/payment/payment-context"; import { AppEnvUtil, Logger, Result } from "@/utils"; import { StripePaymentDialog } from "./stripe-payment-dialog"; import { SubscriptionCtaButton } from "./subscription-cta-button"; import styles from "./subscription-cta-button.module.css"; const EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS = 10000; const log = new Logger("SubscriptionCheckoutButton"); export interface SubscriptionCheckoutButtonProps { /** 是否可用(未选套餐 / 未勾选协议 → false) */ disabled?: boolean; subscriptionType: "vip" | "voice"; } export function SubscriptionCheckoutButton({ disabled = false, subscriptionType, }: SubscriptionCheckoutButtonProps) { const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); const launchedNonceRef = useRef(0); const ezpayRedirectTimeoutRef = useRef(null); const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< string | null >(null); useEffect(() => { return () => { if (ezpayRedirectTimeoutRef.current !== null) { window.clearTimeout(ezpayRedirectTimeoutRef.current); } }; }, []); useEffect(() => { if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { return; } launchedNonceRef.current = payment.launchNonce; const clientSecret = getStripeClientSecret(payment.payParams); if (clientSecret) { return; } const paymentUrl = getPaymentUrl(payment.payParams); if (paymentUrl) { const isEzpay = isEzpayPayment(payment.payParams); if (!AppEnvUtil.isProduction() && isEzpay) { if (ezpayRedirectTimeoutRef.current !== null) { window.clearTimeout(ezpayRedirectTimeoutRef.current); } void redirectToEzpay({ orderId: payment.currentOrderId, paymentUrl, subscriptionType, delayMs: EZPAY_DEVELOPMENT_REDIRECT_DELAY_MS, setTimeoutId: (timeoutId) => { ezpayRedirectTimeoutRef.current = timeoutId; }, }); return; } if (isEzpay) { void redirectToEzpay({ orderId: payment.currentOrderId, paymentUrl, subscriptionType, }); return; } window.location.href = paymentUrl; return; } paymentDispatch({ type: "PaymentLaunchFailed", errorMessage: "Payment parameters did not include a supported URL or Stripe client secret.", }); }, [ payment.currentOrderId, payment.launchNonce, payment.payParams, paymentDispatch, subscriptionType, ]); const isLoading = payment.isCreatingOrder || payment.isPollingOrder || payment.isCheckingVipStatus; const label = payment.isPollingOrder ? "Processing payment..." : payment.isCreatingOrder ? "Creating order..." : payment.agreed ? "Pay and Activiate" : "Confirm the agreement and Activate"; const handleClick = () => { if (disabled || isLoading) return; if (ezpayRedirectTimeoutRef.current !== null) { window.clearTimeout(ezpayRedirectTimeoutRef.current); ezpayRedirectTimeoutRef.current = null; } 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; const pendingEzpayRedirectOrderId = !AppEnvUtil.isProduction() && payment.payParams && isEzpayPayment(payment.payParams) && getPaymentUrl(payment.payParams) && payment.currentOrderId ? payment.currentOrderId : null; return ( <> {label} {payment.errorMessage ? (

{payment.errorMessage}

) : null} {pendingEzpayRedirectOrderId ? (
Ezpay order created. Redirecting in 5 seconds. Order No. {pendingEzpayRedirectOrderId}
) : null} {shouldShowStripeDialog ? ( ) : null} ); } function getPaymentUrl(payParams: Record): string | null { const keys = [ "cashierUrl", "cashier_url", "checkoutUrl", "checkout_url", "paymentUrl", "payment_url", "approvalUrl", "approval_url", "redirectUrl", "redirect_url", "url", ]; for (const key of keys) { const value = payParams[key]; if (typeof value === "string" && value.length > 0) return value; } return null; } function isEzpayPayment(payParams: Record): boolean { const provider = payParams.provider; return typeof provider === "string" && provider.toLowerCase() === "ezpay"; } interface RedirectToEzpayInput { orderId: string | null; paymentUrl: string; subscriptionType: "vip" | "voice"; delayMs?: number; setTimeoutId?: (timeoutId: number) => void; } async function redirectToEzpay({ orderId, paymentUrl, subscriptionType, delayMs = 0, setTimeoutId, }: RedirectToEzpayInput): Promise { log.debug("[subscription-checkout] redirectToEzpay START", { hasOrderId: Boolean(orderId), orderId, subscriptionType, delayMs, paymentUrl, }); if (orderId) { const saveResult = await PendingPaymentOrderStorage.setPendingOrder({ orderId, payChannel: "ezpay", subscriptionType, createdAt: Date.now(), }); if (Result.isOk(saveResult)) { log.debug("[subscription-checkout] pending ezpay order saved", { orderId, subscriptionType, }); } else { log.error("[subscription-checkout] pending ezpay order save failed", { orderId, subscriptionType, error: saveResult.error, }); } } else { log.warn("[subscription-checkout] skip pending ezpay order save: missing orderId", { subscriptionType, paymentUrl, }); } if (delayMs > 0) { const timeoutId = window.setTimeout(() => { log.debug("[subscription-checkout] redirectToEzpay DELAY elapsed", { orderId, subscriptionType, paymentUrl, }); window.location.href = paymentUrl; }, delayMs); setTimeoutId?.(timeoutId); log.debug("[subscription-checkout] redirectToEzpay DELAY scheduled", { orderId, subscriptionType, delayMs, timeoutId, }); return; } log.debug("[subscription-checkout] redirectToEzpay NOW", { orderId, subscriptionType, paymentUrl, }); window.location.href = paymentUrl; } function getStripeClientSecret( payParams: Record, ): 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; }