diff --git a/src/app/_hooks/__tests__/use-payment-launch-flow.test.ts b/src/app/_hooks/__tests__/use-payment-launch-flow.test.ts new file mode 100644 index 00000000..da6d89d4 --- /dev/null +++ b/src/app/_hooks/__tests__/use-payment-launch-flow.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { shouldShowEzpayConfirmation } from "../use-payment-launch-flow"; + +describe("shouldShowEzpayConfirmation", () => { + it("shows confirmation for Ezpay in non-production builds", () => { + expect( + shouldShowEzpayConfirmation({ + currentOrderId: "order-1", + isProduction: false, + payParams: { provider: "ezpay" }, + paymentUrl: "https://pay.example/checkout", + }), + ).toBe(true); + }); + + it("does not show confirmation in production", () => { + expect( + shouldShowEzpayConfirmation({ + currentOrderId: "order-1", + isProduction: true, + payParams: { provider: "ezpay" }, + paymentUrl: "https://pay.example/checkout", + }), + ).toBe(false); + }); + + it("does not show confirmation without an order id", () => { + expect( + shouldShowEzpayConfirmation({ + currentOrderId: null, + isProduction: false, + payParams: { provider: "ezpay" }, + paymentUrl: "https://pay.example/checkout", + }), + ).toBe(false); + }); + + it("does not show confirmation for non-Ezpay providers", () => { + expect( + shouldShowEzpayConfirmation({ + currentOrderId: "order-1", + isProduction: false, + payParams: { provider: "stripe" }, + paymentUrl: "https://pay.example/checkout", + }), + ).toBe(false); + }); +}); diff --git a/src/app/_hooks/use-payment-launch-flow.ts b/src/app/_hooks/use-payment-launch-flow.ts new file mode 100644 index 00000000..6ee28035 --- /dev/null +++ b/src/app/_hooks/use-payment-launch-flow.ts @@ -0,0 +1,208 @@ +"use client"; + +import { type Dispatch, useEffect, useRef, useState } from "react"; + +import { + getPaymentUrl, + getStripeClientSecret, + isEzpayPayment, + launchEzpayRedirect, +} from "@/lib/payment/payment_launch"; +import type { + PendingPaymentReturnTo, + PendingPaymentSubscriptionType, +} from "@/lib/payment/pending_payment_order"; +import type { + PaymentContextState, +} from "@/stores/payment/payment-context"; +import type { PaymentEvent } from "@/stores/payment/payment-events"; +import { AppEnvUtil, Logger } from "@/utils"; + +const UNSUPPORTED_PAYMENT_PARAMS_MESSAGE = + "Payment parameters did not include a supported URL or Stripe client secret."; + +export interface ShouldShowEzpayConfirmationInput { + currentOrderId: string | null; + isProduction: boolean; + payParams: Record | null; + paymentUrl: string | null; +} + +export interface UsePaymentLaunchFlowInput { + log: Logger; + logScope: string; + payment: PaymentContextState; + paymentDispatch: Dispatch; + returnTo?: PendingPaymentReturnTo; + subscriptionType: PendingPaymentSubscriptionType; +} + +export interface PaymentLaunchFlow { + ezpayPaymentUrl: string | null; + handleEzpayCancel: () => void; + handleEzpayConfirm: () => void; + handleStripeClose: () => void; + handleStripeConfirmed: () => void; + isConfirmingEzpay: boolean; + resetLaunchState: () => void; + shouldShowEzpayConfirmDialog: boolean; + shouldShowStripeDialog: boolean; + stripeClientSecret: string | null; +} + +export function shouldShowEzpayConfirmation({ + currentOrderId, + isProduction, + payParams, + paymentUrl, +}: ShouldShowEzpayConfirmationInput): boolean { + return Boolean( + !isProduction && + payParams && + isEzpayPayment(payParams) && + paymentUrl && + currentOrderId, + ); +} + +export function usePaymentLaunchFlow({ + log, + logScope, + payment, + paymentDispatch, + returnTo, + subscriptionType, +}: UsePaymentLaunchFlowInput): PaymentLaunchFlow { + const launchedNonceRef = useRef(0); + const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< + string | null + >(null); + const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false); + const stripeClientSecret = payment.payParams + ? getStripeClientSecret(payment.payParams) + : null; + const ezpayPaymentUrl = payment.payParams + ? getPaymentUrl(payment.payParams) + : null; + + 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) { + log.debug(`[${logScope}] ezpay confirmation required`, { + orderId: payment.currentOrderId, + paymentUrl, + subscriptionType, + }); + return; + } + + if (isEzpay) { + void launchEzpayRedirect({ + orderId: payment.currentOrderId, + paymentUrl, + subscriptionType, + ...(returnTo ? { returnTo } : {}), + onFailed: (errorMessage) => + paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }), + }); + return; + } + + window.location.href = paymentUrl; + return; + } + + paymentDispatch({ + type: "PaymentLaunchFailed", + errorMessage: UNSUPPORTED_PAYMENT_PARAMS_MESSAGE, + }); + }, [ + log, + logScope, + payment.currentOrderId, + payment.launchNonce, + payment.payParams, + paymentDispatch, + returnTo, + subscriptionType, + ]); + + const shouldShowStripeDialog = + stripeClientSecret !== null && + stripeClientSecret !== hiddenStripeClientSecret && + !payment.isPaid; + const shouldShowEzpayConfirmDialog = shouldShowEzpayConfirmation({ + currentOrderId: payment.currentOrderId, + isProduction: AppEnvUtil.isProduction(), + payParams: payment.payParams, + paymentUrl: ezpayPaymentUrl, + }); + + function resetLaunchState(): void { + setIsConfirmingEzpay(false); + setHiddenStripeClientSecret(null); + } + + function handleStripeClose(): void { + if (stripeClientSecret) { + setHiddenStripeClientSecret(stripeClientSecret); + } + if (payment.orderStatus !== "paid") { + paymentDispatch({ type: "PaymentReset" }); + } + } + + function handleStripeConfirmed(): void { + if (stripeClientSecret) { + setHiddenStripeClientSecret(stripeClientSecret); + } + } + + function handleEzpayConfirm(): void { + if (!payment.currentOrderId || !ezpayPaymentUrl) return; + setIsConfirmingEzpay(true); + void launchEzpayRedirect({ + orderId: payment.currentOrderId, + paymentUrl: ezpayPaymentUrl, + subscriptionType, + ...(returnTo ? { returnTo } : {}), + onFailed: (errorMessage) => { + setIsConfirmingEzpay(false); + paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); + }, + }); + } + + function handleEzpayCancel(): void { + log.debug(`[${logScope}] ezpay confirmation cancelled`, { + orderId: payment.currentOrderId, + subscriptionType, + }); + setIsConfirmingEzpay(false); + paymentDispatch({ type: "PaymentReset" }); + } + + return { + ezpayPaymentUrl, + handleEzpayCancel, + handleEzpayConfirm, + handleStripeClose, + handleStripeConfirmed, + isConfirmingEzpay, + resetLaunchState, + shouldShowEzpayConfirmDialog, + shouldShowStripeDialog, + stripeClientSecret, + }; +} diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx index 38b0bc70..9ce81d8f 100644 --- a/src/app/subscription/components/subscription-checkout-button.tsx +++ b/src/app/subscription/components/subscription-checkout-button.tsx @@ -4,19 +4,12 @@ * * 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。 */ -import { useEffect, useRef, useState } from "react"; - -import { - getPaymentUrl, - getStripeClientSecret, - isEzpayPayment, - launchEzpayRedirect, -} from "@/lib/payment/payment_launch"; +import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { usePaymentDispatch, usePaymentState, } from "@/stores/payment/payment-context"; -import { AppEnvUtil, Logger } from "@/utils"; +import { Logger } from "@/utils"; import { StripePaymentDialog } from "./stripe-payment-dialog"; import dialogStyles from "./stripe-payment-dialog.module.css"; @@ -39,65 +32,14 @@ export function SubscriptionCheckoutButton({ }: SubscriptionCheckoutButtonProps) { const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); - const launchedNonceRef = useRef(0); - const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< - string | null - >(null); - const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false); - - 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) { - log.debug("[subscription-checkout] ezpay confirmation required", { - orderId: payment.currentOrderId, - paymentUrl, - subscriptionType, - }); - return; - } - - if (isEzpay) { - void launchEzpayRedirect({ - orderId: payment.currentOrderId, - paymentUrl, - subscriptionType, - returnTo: returnTo ?? undefined, - onFailed: (errorMessage) => - paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }), - }); - 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, + const paymentLaunch = usePaymentLaunchFlow({ + log, + logScope: "subscription-checkout", + payment, paymentDispatch, - returnTo, + returnTo: returnTo ?? undefined, subscriptionType, - ]); + }); const isLoading = payment.isCreatingOrder || @@ -118,69 +60,10 @@ export function SubscriptionCheckoutButton({ const handleClick = () => { if (disabled || isLoading) return; - setIsConfirmingEzpay(false); - setHiddenStripeClientSecret(null); + paymentLaunch.resetLaunchState(); 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 ezpayPaymentUrl = payment.payParams - ? getPaymentUrl(payment.payParams) - : null; - const shouldShowEzpayConfirmDialog = - !AppEnvUtil.isProduction() && - payment.payParams && - isEzpayPayment(payment.payParams) && - ezpayPaymentUrl && - payment.currentOrderId - ? true - : false; - - const handleEzpayConfirm = () => { - if (!payment.currentOrderId || !ezpayPaymentUrl) return; - setIsConfirmingEzpay(true); - void launchEzpayRedirect({ - orderId: payment.currentOrderId, - paymentUrl: ezpayPaymentUrl, - subscriptionType, - returnTo: returnTo ?? undefined, - onFailed: (errorMessage) => { - setIsConfirmingEzpay(false); - paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); - }, - }); - }; - - const handleEzpayCancel = () => { - log.debug("[subscription-checkout] ezpay confirmation cancelled", { - orderId: payment.currentOrderId, - subscriptionType, - }); - setIsConfirmingEzpay(false); - paymentDispatch({ type: "PaymentReset" }); - }; - return ( <> ) : null} - {shouldShowEzpayConfirmDialog ? ( + {paymentLaunch.shouldShowEzpayConfirmDialog ? ( ) : null} - {shouldShowStripeDialog ? ( + {paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? ( ) : null} diff --git a/src/app/tip/tip-checkout-button.tsx b/src/app/tip/tip-checkout-button.tsx index 507307d2..46bab37f 100644 --- a/src/app/tip/tip-checkout-button.tsx +++ b/src/app/tip/tip-checkout-button.tsx @@ -1,19 +1,12 @@ "use client"; -import { useEffect, useRef, useState } from "react"; - -import { - getPaymentUrl, - getStripeClientSecret, - isEzpayPayment, - launchEzpayRedirect, -} from "@/lib/payment/payment_launch"; +import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; import { ROUTES } from "@/router/routes"; import { usePaymentDispatch, usePaymentState, } from "@/stores/payment/payment-context"; -import { AppEnvUtil, Logger } from "@/utils"; +import { Logger } from "@/utils"; import { StripePaymentDialog } from "../subscription/components/stripe-payment-dialog"; import dialogStyles from "../subscription/components/stripe-payment-dialog.module.css"; @@ -34,80 +27,16 @@ export function TipCheckoutButton({ }: TipCheckoutButtonProps) { const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); - const launchedNonceRef = useRef(0); - const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< - string | null - >(null); - const [isConfirmingEzpay, setIsConfirmingEzpay] = useState(false); - - 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) { - log.debug("[tip-checkout] ezpay confirmation required", { - orderId: payment.currentOrderId, - paymentUrl, - }); - return; - } - - if (isEzpay) { - void launchEzpayRedirect({ - orderId: payment.currentOrderId, - paymentUrl, - subscriptionType: "tip", - onFailed: (errorMessage) => - paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }), - }); - 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, + const paymentLaunch = usePaymentLaunchFlow({ + log, + logScope: "tip-checkout", + payment, paymentDispatch, - ]); + subscriptionType: "tip", + }); const isLoading = isAuthLoading || payment.isCreatingOrder || payment.isPollingOrder; - const stripeClientSecret = payment.payParams - ? getStripeClientSecret(payment.payParams) - : null; - const shouldShowStripeDialog = - stripeClientSecret !== null && - stripeClientSecret !== hiddenStripeClientSecret && - !payment.isPaid; - const ezpayPaymentUrl = payment.payParams - ? getPaymentUrl(payment.payParams) - : null; - const shouldShowEzpayConfirmDialog = - !AppEnvUtil.isProduction() && - payment.payParams && - isEzpayPayment(payment.payParams) && - ezpayPaymentUrl && - payment.currentOrderId - ? true - : false; const label = payment.isPollingOrder ? "Processing payment..." : payment.isCreatingOrder @@ -116,43 +45,6 @@ export function TipCheckoutButton({ ? "Thanks for the coffee" : "Order and Buy"; - const handleStripeClose = () => { - if (stripeClientSecret) { - setHiddenStripeClientSecret(stripeClientSecret); - } - if (payment.orderStatus !== "paid") { - paymentDispatch({ type: "PaymentReset" }); - } - }; - - const handleStripeConfirmed = () => { - if (stripeClientSecret) { - setHiddenStripeClientSecret(stripeClientSecret); - } - }; - - const handleEzpayConfirm = () => { - if (!payment.currentOrderId || !ezpayPaymentUrl) return; - setIsConfirmingEzpay(true); - void launchEzpayRedirect({ - orderId: payment.currentOrderId, - paymentUrl: ezpayPaymentUrl, - subscriptionType: "tip", - onFailed: (errorMessage) => { - setIsConfirmingEzpay(false); - paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); - }, - }); - }; - - const handleEzpayCancel = () => { - log.debug("[tip-checkout] ezpay confirmation cancelled", { - orderId: payment.currentOrderId, - }); - setIsConfirmingEzpay(false); - paymentDispatch({ type: "PaymentReset" }); - }; - return ( <>