From 590dee417b2f4d34755df19970fb0bf6c8d9ec18 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 15 Jul 2026 18:47:01 +0800 Subject: [PATCH] refactor(payment): centralize route lifecycle --- ...e-pending-payment-order-lifecycle.test.ts} | 2 +- src/app/_hooks/use-payment-route-flow.ts | 83 +++++++++++++++++++ ...=> use-pending-payment-order-lifecycle.ts} | 29 +------ src/app/subscription/page.tsx | 37 +++++---- .../use-subscription-payment-flow.ts | 49 ++--------- src/app/tip/page.tsx | 27 +++--- src/app/tip/tip-screen.tsx | 50 ++--------- .../__tests__/payment_search_params.test.ts | 38 +++++++++ src/lib/payment/payment_search_params.ts | 32 +++++++ 9 files changed, 203 insertions(+), 144 deletions(-) rename src/app/_hooks/__tests__/{use-payment-order-lifecycle.test.ts => use-pending-payment-order-lifecycle.test.ts} (92%) create mode 100644 src/app/_hooks/use-payment-route-flow.ts rename src/app/_hooks/{use-payment-order-lifecycle.ts => use-pending-payment-order-lifecycle.ts} (76%) create mode 100644 src/lib/payment/__tests__/payment_search_params.test.ts create mode 100644 src/lib/payment/payment_search_params.ts diff --git a/src/app/_hooks/__tests__/use-payment-order-lifecycle.test.ts b/src/app/_hooks/__tests__/use-pending-payment-order-lifecycle.test.ts similarity index 92% rename from src/app/_hooks/__tests__/use-payment-order-lifecycle.test.ts rename to src/app/_hooks/__tests__/use-pending-payment-order-lifecycle.test.ts index e7b53cef..d762c97e 100644 --- a/src/app/_hooks/__tests__/use-payment-order-lifecycle.test.ts +++ b/src/app/_hooks/__tests__/use-pending-payment-order-lifecycle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { shouldInspectPendingPaymentOrder } from "../use-payment-order-lifecycle"; +import { shouldInspectPendingPaymentOrder } from "../use-pending-payment-order-lifecycle"; describe("shouldInspectPendingPaymentOrder", () => { it("allows pending order inspection when payment plans are ready", () => { diff --git a/src/app/_hooks/use-payment-route-flow.ts b/src/app/_hooks/use-payment-route-flow.ts new file mode 100644 index 00000000..64107fab --- /dev/null +++ b/src/app/_hooks/use-payment-route-flow.ts @@ -0,0 +1,83 @@ +"use client"; + +import { type Dispatch, useEffect, useRef } from "react"; + +import type { PayChannel } from "@/data/dto/payment"; +import type { PendingPaymentSubscriptionType } from "@/lib/payment/pending_payment_order"; +import { + usePaymentDispatch, + usePaymentState, + type PaymentContextState, +} from "@/stores/payment/payment-context"; +import type { PaymentEvent } from "@/stores/payment/payment-events"; +import type { PaymentPlanCatalog } from "@/stores/payment/payment-state"; + +import { usePendingPaymentOrderLifecycle } from "./use-pending-payment-order-lifecycle"; + +export interface UsePaymentRouteFlowInput { + catalog?: PaymentPlanCatalog; + initialPayChannel: PayChannel; + paymentType: PendingPaymentSubscriptionType; + shouldResumePendingOrder: boolean; +} + +export interface PaymentRouteFlow { + payment: PaymentContextState; + paymentDispatch: Dispatch; +} + +/** + * Owns the lifecycle shared by payment entry routes: + * initialize the requested catalog and restore or clean up redirect orders. + */ +export function usePaymentRouteFlow({ + catalog = "default", + initialPayChannel, + paymentType, + shouldResumePendingOrder, +}: UsePaymentRouteFlowInput): PaymentRouteFlow { + const payment = usePaymentState(); + const paymentDispatch = usePaymentDispatch(); + const initialPayChannelAppliedRef = useRef(false); + + usePendingPaymentOrderLifecycle({ + payment, + paymentDispatch, + paymentType, + shouldResumePendingOrder, + }); + + useEffect(() => { + if (payment.status === "idle") { + initialPayChannelAppliedRef.current = true; + paymentDispatch({ + type: "PaymentInit", + catalog, + payChannel: initialPayChannel, + }); + return; + } + + if ( + initialPayChannelAppliedRef.current || + payment.status !== "ready" + ) { + return; + } + + initialPayChannelAppliedRef.current = true; + if (payment.payChannel === initialPayChannel) return; + paymentDispatch({ + type: "PaymentPayChannelChanged", + payChannel: initialPayChannel, + }); + }, [ + catalog, + initialPayChannel, + payment.payChannel, + payment.status, + paymentDispatch, + ]); + + return { payment, paymentDispatch }; +} diff --git a/src/app/_hooks/use-payment-order-lifecycle.ts b/src/app/_hooks/use-pending-payment-order-lifecycle.ts similarity index 76% rename from src/app/_hooks/use-payment-order-lifecycle.ts rename to src/app/_hooks/use-pending-payment-order-lifecycle.ts index 743f3f38..4efde8fd 100644 --- a/src/app/_hooks/use-payment-order-lifecycle.ts +++ b/src/app/_hooks/use-pending-payment-order-lifecycle.ts @@ -7,11 +7,7 @@ import { getPendingPaymentOrderForType, type PendingPaymentSubscriptionType, } from "@/lib/payment/pending_payment_order"; -import type { - PaymentContextState, -} from "@/stores/payment/payment-context"; import type { PaymentEvent } from "@/stores/payment/payment-events"; -import { useUserDispatch } from "@/stores/user/user-context"; export interface PaymentOrderLifecycleState { currentOrderId: string | null; @@ -25,12 +21,11 @@ export interface ShouldInspectPendingPaymentOrderInput shouldResumePendingOrder: boolean; } -export interface UsePaymentOrderLifecycleInput { - payment: PaymentContextState; +export interface UsePendingPaymentOrderLifecycleInput { + payment: PaymentOrderLifecycleState; paymentDispatch: Dispatch; paymentType: PendingPaymentSubscriptionType; shouldResumePendingOrder: boolean; - refreshUserOnPaid?: boolean; } export function shouldInspectPendingPaymentOrder({ @@ -46,30 +41,14 @@ export function shouldInspectPendingPaymentOrder({ ); } -export function usePaymentOrderLifecycle({ +export function usePendingPaymentOrderLifecycle({ payment, paymentDispatch, paymentType, - refreshUserOnPaid = true, shouldResumePendingOrder, -}: UsePaymentOrderLifecycleInput): void { - const userDispatch = useUserDispatch(); - const refreshedPaidOrderRef = useRef(null); +}: UsePendingPaymentOrderLifecycleInput): void { const resumedPendingOrderRef = useRef(null); - useEffect(() => { - if (!refreshUserOnPaid) return; - if (!payment.isPaid || !payment.currentOrderId) return; - if (refreshedPaidOrderRef.current === payment.currentOrderId) return; - refreshedPaidOrderRef.current = payment.currentOrderId; - userDispatch({ type: "UserFetch" }); - }, [ - payment.currentOrderId, - payment.isPaid, - refreshUserOnPaid, - userDispatch, - ]); - useEffect(() => { if ( !shouldInspectPendingPaymentOrder({ diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx index 0398bf63..bd80469b 100644 --- a/src/app/subscription/page.tsx +++ b/src/app/subscription/page.tsx @@ -1,45 +1,50 @@ -import type { PayChannel } from "@/data/dto/payment"; import { PAYMENT_ANALYTICS_ENTRY_PARAM, PAYMENT_ANALYTICS_REASON_PARAM, parsePaymentAnalyticsContext, } from "@/lib/analytics/payment_analytics_context"; +import { + getFirstPaymentSearchParam, + parsePaymentReturnSearchParams, + type PaymentSearchParams, +} from "@/lib/payment/payment_search_params"; import { SubscriptionScreen, type SubscriptionType, } from "./subscription-screen"; -type SubscriptionSearchParams = Record; - export default async function SubscriptionPage({ searchParams, }: { - searchParams: Promise; + searchParams: Promise; }) { const query = await searchParams; - const subscriptionType = toSubscriptionType(firstSearchParam(query.type)); + const paymentReturn = parsePaymentReturnSearchParams(query); + const subscriptionType = toSubscriptionType( + getFirstPaymentSearchParam(query.type), + ); const analyticsContext = parsePaymentAnalyticsContext({ - entryPoint: firstSearchParam(query[PAYMENT_ANALYTICS_ENTRY_PARAM]), - triggerReason: firstSearchParam(query[PAYMENT_ANALYTICS_REASON_PARAM]), + entryPoint: getFirstPaymentSearchParam( + query[PAYMENT_ANALYTICS_ENTRY_PARAM], + ), + triggerReason: getFirstPaymentSearchParam( + query[PAYMENT_ANALYTICS_REASON_PARAM], + ), subscriptionType, }); return ( ); } -function firstSearchParam(value: string | string[] | undefined): string | null { - return Array.isArray(value) ? (value[0] ?? null) : (value ?? null); -} - function toSubscriptionType(value: string | null): SubscriptionType { return value === "topup" ? "topup" : "vip"; } @@ -47,7 +52,3 @@ function toSubscriptionType(value: string | null): SubscriptionType { function toReturnTo(value: string | null): "chat" | null { return value === "chat" ? "chat" : null; } - -function toPayChannel(value: string | null): PayChannel | null { - return value === "ezpay" || value === "stripe" ? value : null; -} diff --git a/src/app/subscription/use-subscription-payment-flow.ts b/src/app/subscription/use-subscription-payment-flow.ts index 58a5bbfa..9d6432b9 100644 --- a/src/app/subscription/use-subscription-payment-flow.ts +++ b/src/app/subscription/use-subscription-payment-flow.ts @@ -2,12 +2,8 @@ import { useEffect, useRef, useState } from "react"; -import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle"; +import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow"; import { useAppNavigator } from "@/router/use-app-navigator"; -import { - usePaymentDispatch, - usePaymentState, -} from "@/stores/payment/payment-context"; import type { PayChannel } from "@/data/dto/payment"; import type { SubscriptionType } from "./subscription-screen.helpers"; @@ -26,47 +22,14 @@ export function useSubscriptionPaymentFlow({ initialPayChannel, }: UseSubscriptionPaymentFlowInput) { const navigator = useAppNavigator(); - const payment = usePaymentState(); - const paymentDispatch = usePaymentDispatch(); - const successDialogShownOrderRef = useRef(null); - const initialPayChannelAppliedRef = useRef(false); - const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = - useState(false); - - usePaymentOrderLifecycle({ - payment, - paymentDispatch, + const { payment, paymentDispatch } = usePaymentRouteFlow({ + initialPayChannel, paymentType: subscriptionType, shouldResumePendingOrder, }); - - useEffect(() => { - if (payment.status === "idle") { - initialPayChannelAppliedRef.current = true; - paymentDispatch({ - type: "PaymentInit", - payChannel: initialPayChannel, - }); - return; - } - - if ( - !initialPayChannelAppliedRef.current && - payment.status === "ready" - ) { - initialPayChannelAppliedRef.current = true; - if (payment.payChannel === initialPayChannel) return; - paymentDispatch({ - type: "PaymentPayChannelChanged", - payChannel: initialPayChannel, - }); - } - }, [ - initialPayChannel, - payment.payChannel, - payment.status, - paymentDispatch, - ]); + const successDialogShownOrderRef = useRef(null); + const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = + useState(false); useEffect(() => { if (!payment.isPaid || !payment.currentOrderId) return; diff --git a/src/app/tip/page.tsx b/src/app/tip/page.tsx index f8d0077e..a7d72f68 100644 --- a/src/app/tip/page.tsx +++ b/src/app/tip/page.tsx @@ -1,4 +1,8 @@ -import type { PayChannel } from "@/data/dto/payment"; +import { + getFirstPaymentSearchParam, + parsePaymentReturnSearchParams, + type PaymentSearchParams, +} from "@/lib/payment/payment_search_params"; import { DEFAULT_TIP_COFFEE_TYPE, resolveTipCoffeeType, @@ -7,31 +11,24 @@ import { import { TipScreen } from "./tip-screen"; -type TipSearchParams = Record; - export default async function TipPage({ searchParams, }: { - searchParams: Promise; + searchParams: Promise; }) { const query = await searchParams; + const paymentReturn = parsePaymentReturnSearchParams(query); const coffeeType = - resolveTipCoffeeType(firstSearchParam(query[TIP_COFFEE_TYPE_PARAM])) ?? + resolveTipCoffeeType( + getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]), + ) ?? DEFAULT_TIP_COFFEE_TYPE; return ( ); } - -function firstSearchParam(value: string | string[] | undefined): string | null { - return Array.isArray(value) ? (value[0] ?? null) : (value ?? null); -} - -function toPayChannel(value: string | null): PayChannel | null { - return value === "ezpay" || value === "stripe" ? value : null; -} diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index 57bc7e69..b6bb78ca 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -1,13 +1,13 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Image from "next/image"; import { ArrowLeft, Heart, Sparkles } from "lucide-react"; import { CharacterAvatar } from "@/app/_components"; import { MobileShell } from "@/app/_components/core"; -import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle"; import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics"; +import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow"; import type { PayChannel } from "@/data/dto/payment"; import { behaviorAnalytics, @@ -22,10 +22,6 @@ import { } from "@/lib/tip/tip_coffee"; import { useAppNavigator } from "@/router/use-app-navigator"; import { useAuthState } from "@/stores/auth/auth-context"; -import { - usePaymentDispatch, - usePaymentState, -} from "@/stores/payment/payment-context"; import { TipCheckoutButton } from "./tip-checkout-button"; import { @@ -57,15 +53,18 @@ export function TipScreen({ }: TipScreenProps) { const navigator = useAppNavigator(); const authState = useAuthState(); - const payment = usePaymentState(); - const paymentDispatch = usePaymentDispatch(); - const initialPayChannelAppliedRef = useRef(false); const [selectedCoffeeType, setSelectedCoffeeType] = useState(coffeeType); const coffeeOption = getTipCoffeeOption(selectedCoffeeType); const returnPath = buildTipCoffeePath(selectedCoffeeType); const resolvedInitialPayChannel = initialPayChannel ?? navigator.getDefaultPayChannel(); + const { payment, paymentDispatch } = usePaymentRouteFlow({ + catalog: "tip", + initialPayChannel: resolvedInitialPayChannel, + paymentType: "tip", + shouldResumePendingOrder, + }); const coffeeTiers = useMemo( () => @@ -113,39 +112,6 @@ export function TipScreen({ payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy; const isAuthLoading = !authState.hasInitialized || authState.isLoading; - usePaymentOrderLifecycle({ - payment, - paymentDispatch, - paymentType: "tip", - shouldResumePendingOrder, - }); - - useEffect(() => { - if (payment.status === "idle") { - initialPayChannelAppliedRef.current = true; - paymentDispatch({ - type: "PaymentInit", - catalog: "tip", - payChannel: resolvedInitialPayChannel, - }); - return; - } - - if (!initialPayChannelAppliedRef.current && payment.status === "ready") { - initialPayChannelAppliedRef.current = true; - if (payment.payChannel === resolvedInitialPayChannel) return; - paymentDispatch({ - type: "PaymentPayChannelChanged", - payChannel: resolvedInitialPayChannel, - }); - } - }, [ - payment.payChannel, - payment.status, - paymentDispatch, - resolvedInitialPayChannel, - ]); - useEffect(() => { if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) { return; diff --git a/src/lib/payment/__tests__/payment_search_params.test.ts b/src/lib/payment/__tests__/payment_search_params.test.ts new file mode 100644 index 00000000..5732ff35 --- /dev/null +++ b/src/lib/payment/__tests__/payment_search_params.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { + getFirstPaymentSearchParam, + parsePaymentPayChannel, + parsePaymentReturnSearchParams, +} from "../payment_search_params"; + +describe("payment search params", () => { + it("takes the first value from repeated query parameters", () => { + expect(getFirstPaymentSearchParam(["first", "second"])).toBe("first"); + expect(getFirstPaymentSearchParam([])).toBeNull(); + }); + + it("accepts only supported payment channels", () => { + expect(parsePaymentPayChannel("stripe")).toBe("stripe"); + expect(parsePaymentPayChannel(["ezpay", "stripe"])).toBe("ezpay"); + expect(parsePaymentPayChannel("paypal")).toBeNull(); + expect(parsePaymentPayChannel(undefined)).toBeNull(); + }); + + it("parses the shared payment return context", () => { + expect( + parsePaymentReturnSearchParams({ + payChannel: ["ezpay", "stripe"], + paymentReturn: "1", + }), + ).toEqual({ + initialPayChannel: "ezpay", + shouldResumePendingOrder: true, + }); + + expect(parsePaymentReturnSearchParams({ paymentReturn: "0" })).toEqual({ + initialPayChannel: null, + shouldResumePendingOrder: false, + }); + }); +}); diff --git a/src/lib/payment/payment_search_params.ts b/src/lib/payment/payment_search_params.ts new file mode 100644 index 00000000..6f7eb1bd --- /dev/null +++ b/src/lib/payment/payment_search_params.ts @@ -0,0 +1,32 @@ +import type { PayChannel } from "@/data/dto/payment"; + +export type PaymentSearchParamValue = string | string[] | undefined; +export type PaymentSearchParams = Record; + +export interface PaymentReturnSearchParams { + initialPayChannel: PayChannel | null; + shouldResumePendingOrder: boolean; +} + +export function getFirstPaymentSearchParam( + value: PaymentSearchParamValue, +): string | null { + return Array.isArray(value) ? (value[0] ?? null) : (value ?? null); +} + +export function parsePaymentPayChannel( + value: PaymentSearchParamValue, +): PayChannel | null { + const channel = getFirstPaymentSearchParam(value); + return channel === "ezpay" || channel === "stripe" ? channel : null; +} + +export function parsePaymentReturnSearchParams( + searchParams: PaymentSearchParams, +): PaymentReturnSearchParams { + return { + initialPayChannel: parsePaymentPayChannel(searchParams.payChannel), + shouldResumePendingOrder: + getFirstPaymentSearchParam(searchParams.paymentReturn) === "1", + }; +}