refactor(payment): centralize route lifecycle

This commit is contained in:
2026-07-15 18:47:01 +08:00
parent 7dce1b5d4c
commit 590dee417b
9 changed files with 203 additions and 144 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { shouldInspectPendingPaymentOrder } from "../use-payment-order-lifecycle"; import { shouldInspectPendingPaymentOrder } from "../use-pending-payment-order-lifecycle";
describe("shouldInspectPendingPaymentOrder", () => { describe("shouldInspectPendingPaymentOrder", () => {
it("allows pending order inspection when payment plans are ready", () => { it("allows pending order inspection when payment plans are ready", () => {
+83
View File
@@ -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<PaymentEvent>;
}
/**
* 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 };
}
@@ -7,11 +7,7 @@ import {
getPendingPaymentOrderForType, getPendingPaymentOrderForType,
type PendingPaymentSubscriptionType, type PendingPaymentSubscriptionType,
} from "@/lib/payment/pending_payment_order"; } from "@/lib/payment/pending_payment_order";
import type {
PaymentContextState,
} from "@/stores/payment/payment-context";
import type { PaymentEvent } from "@/stores/payment/payment-events"; import type { PaymentEvent } from "@/stores/payment/payment-events";
import { useUserDispatch } from "@/stores/user/user-context";
export interface PaymentOrderLifecycleState { export interface PaymentOrderLifecycleState {
currentOrderId: string | null; currentOrderId: string | null;
@@ -25,12 +21,11 @@ export interface ShouldInspectPendingPaymentOrderInput
shouldResumePendingOrder: boolean; shouldResumePendingOrder: boolean;
} }
export interface UsePaymentOrderLifecycleInput { export interface UsePendingPaymentOrderLifecycleInput {
payment: PaymentContextState; payment: PaymentOrderLifecycleState;
paymentDispatch: Dispatch<PaymentEvent>; paymentDispatch: Dispatch<PaymentEvent>;
paymentType: PendingPaymentSubscriptionType; paymentType: PendingPaymentSubscriptionType;
shouldResumePendingOrder: boolean; shouldResumePendingOrder: boolean;
refreshUserOnPaid?: boolean;
} }
export function shouldInspectPendingPaymentOrder({ export function shouldInspectPendingPaymentOrder({
@@ -46,30 +41,14 @@ export function shouldInspectPendingPaymentOrder({
); );
} }
export function usePaymentOrderLifecycle({ export function usePendingPaymentOrderLifecycle({
payment, payment,
paymentDispatch, paymentDispatch,
paymentType, paymentType,
refreshUserOnPaid = true,
shouldResumePendingOrder, shouldResumePendingOrder,
}: UsePaymentOrderLifecycleInput): void { }: UsePendingPaymentOrderLifecycleInput): void {
const userDispatch = useUserDispatch();
const refreshedPaidOrderRef = useRef<string | null>(null);
const resumedPendingOrderRef = useRef<string | null>(null); const resumedPendingOrderRef = useRef<string | null>(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(() => { useEffect(() => {
if ( if (
!shouldInspectPendingPaymentOrder({ !shouldInspectPendingPaymentOrder({
+19 -18
View File
@@ -1,45 +1,50 @@
import type { PayChannel } from "@/data/dto/payment";
import { import {
PAYMENT_ANALYTICS_ENTRY_PARAM, PAYMENT_ANALYTICS_ENTRY_PARAM,
PAYMENT_ANALYTICS_REASON_PARAM, PAYMENT_ANALYTICS_REASON_PARAM,
parsePaymentAnalyticsContext, parsePaymentAnalyticsContext,
} from "@/lib/analytics/payment_analytics_context"; } from "@/lib/analytics/payment_analytics_context";
import {
getFirstPaymentSearchParam,
parsePaymentReturnSearchParams,
type PaymentSearchParams,
} from "@/lib/payment/payment_search_params";
import { import {
SubscriptionScreen, SubscriptionScreen,
type SubscriptionType, type SubscriptionType,
} from "./subscription-screen"; } from "./subscription-screen";
type SubscriptionSearchParams = Record<string, string | string[] | undefined>;
export default async function SubscriptionPage({ export default async function SubscriptionPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<SubscriptionSearchParams>; searchParams: Promise<PaymentSearchParams>;
}) { }) {
const query = await searchParams; const query = await searchParams;
const subscriptionType = toSubscriptionType(firstSearchParam(query.type)); const paymentReturn = parsePaymentReturnSearchParams(query);
const subscriptionType = toSubscriptionType(
getFirstPaymentSearchParam(query.type),
);
const analyticsContext = parsePaymentAnalyticsContext({ const analyticsContext = parsePaymentAnalyticsContext({
entryPoint: firstSearchParam(query[PAYMENT_ANALYTICS_ENTRY_PARAM]), entryPoint: getFirstPaymentSearchParam(
triggerReason: firstSearchParam(query[PAYMENT_ANALYTICS_REASON_PARAM]), query[PAYMENT_ANALYTICS_ENTRY_PARAM],
),
triggerReason: getFirstPaymentSearchParam(
query[PAYMENT_ANALYTICS_REASON_PARAM],
),
subscriptionType, subscriptionType,
}); });
return ( return (
<SubscriptionScreen <SubscriptionScreen
subscriptionType={subscriptionType} subscriptionType={subscriptionType}
shouldResumePendingOrder={firstSearchParam(query.paymentReturn) === "1"} shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
returnTo={toReturnTo(firstSearchParam(query.returnTo))} returnTo={toReturnTo(getFirstPaymentSearchParam(query.returnTo))}
initialPayChannel={toPayChannel(firstSearchParam(query.payChannel))} initialPayChannel={paymentReturn.initialPayChannel}
analyticsContext={analyticsContext} analyticsContext={analyticsContext}
/> />
); );
} }
function firstSearchParam(value: string | string[] | undefined): string | null {
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
}
function toSubscriptionType(value: string | null): SubscriptionType { function toSubscriptionType(value: string | null): SubscriptionType {
return value === "topup" ? "topup" : "vip"; return value === "topup" ? "topup" : "vip";
} }
@@ -47,7 +52,3 @@ function toSubscriptionType(value: string | null): SubscriptionType {
function toReturnTo(value: string | null): "chat" | null { function toReturnTo(value: string | null): "chat" | null {
return value === "chat" ? "chat" : null; return value === "chat" ? "chat" : null;
} }
function toPayChannel(value: string | null): PayChannel | null {
return value === "ezpay" || value === "stripe" ? value : null;
}
@@ -2,12 +2,8 @@
import { useEffect, useRef, useState } from "react"; 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 { useAppNavigator } from "@/router/use-app-navigator";
import {
usePaymentDispatch,
usePaymentState,
} from "@/stores/payment/payment-context";
import type { PayChannel } from "@/data/dto/payment"; import type { PayChannel } from "@/data/dto/payment";
import type { SubscriptionType } from "./subscription-screen.helpers"; import type { SubscriptionType } from "./subscription-screen.helpers";
@@ -26,47 +22,14 @@ export function useSubscriptionPaymentFlow({
initialPayChannel, initialPayChannel,
}: UseSubscriptionPaymentFlowInput) { }: UseSubscriptionPaymentFlowInput) {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const payment = usePaymentState(); const { payment, paymentDispatch } = usePaymentRouteFlow({
const paymentDispatch = usePaymentDispatch(); initialPayChannel,
const successDialogShownOrderRef = useRef<string | null>(null);
const initialPayChannelAppliedRef = useRef(false);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
useState(false);
usePaymentOrderLifecycle({
payment,
paymentDispatch,
paymentType: subscriptionType, paymentType: subscriptionType,
shouldResumePendingOrder, shouldResumePendingOrder,
}); });
const successDialogShownOrderRef = useRef<string | null>(null);
useEffect(() => { const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
if (payment.status === "idle") { useState(false);
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,
]);
useEffect(() => { useEffect(() => {
if (!payment.isPaid || !payment.currentOrderId) return; if (!payment.isPaid || !payment.currentOrderId) return;
+12 -15
View File
@@ -1,4 +1,8 @@
import type { PayChannel } from "@/data/dto/payment"; import {
getFirstPaymentSearchParam,
parsePaymentReturnSearchParams,
type PaymentSearchParams,
} from "@/lib/payment/payment_search_params";
import { import {
DEFAULT_TIP_COFFEE_TYPE, DEFAULT_TIP_COFFEE_TYPE,
resolveTipCoffeeType, resolveTipCoffeeType,
@@ -7,31 +11,24 @@ import {
import { TipScreen } from "./tip-screen"; import { TipScreen } from "./tip-screen";
type TipSearchParams = Record<string, string | string[] | undefined>;
export default async function TipPage({ export default async function TipPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<TipSearchParams>; searchParams: Promise<PaymentSearchParams>;
}) { }) {
const query = await searchParams; const query = await searchParams;
const paymentReturn = parsePaymentReturnSearchParams(query);
const coffeeType = const coffeeType =
resolveTipCoffeeType(firstSearchParam(query[TIP_COFFEE_TYPE_PARAM])) ?? resolveTipCoffeeType(
getFirstPaymentSearchParam(query[TIP_COFFEE_TYPE_PARAM]),
) ??
DEFAULT_TIP_COFFEE_TYPE; DEFAULT_TIP_COFFEE_TYPE;
return ( return (
<TipScreen <TipScreen
coffeeType={coffeeType} coffeeType={coffeeType}
shouldResumePendingOrder={firstSearchParam(query.paymentReturn) === "1"} shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
initialPayChannel={toPayChannel(firstSearchParam(query.payChannel))} initialPayChannel={paymentReturn.initialPayChannel}
/> />
); );
} }
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;
}
+8 -42
View File
@@ -1,13 +1,13 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { ArrowLeft, Heart, Sparkles } from "lucide-react"; import { ArrowLeft, Heart, Sparkles } from "lucide-react";
import { CharacterAvatar } from "@/app/_components"; import { CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; 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 { 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 type { PayChannel } from "@/data/dto/payment";
import { import {
behaviorAnalytics, behaviorAnalytics,
@@ -22,10 +22,6 @@ import {
} from "@/lib/tip/tip_coffee"; } from "@/lib/tip/tip_coffee";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import {
usePaymentDispatch,
usePaymentState,
} from "@/stores/payment/payment-context";
import { TipCheckoutButton } from "./tip-checkout-button"; import { TipCheckoutButton } from "./tip-checkout-button";
import { import {
@@ -57,15 +53,18 @@ export function TipScreen({
}: TipScreenProps) { }: TipScreenProps) {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const authState = useAuthState(); const authState = useAuthState();
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const initialPayChannelAppliedRef = useRef(false);
const [selectedCoffeeType, setSelectedCoffeeType] = const [selectedCoffeeType, setSelectedCoffeeType] =
useState<TipCoffeeType>(coffeeType); useState<TipCoffeeType>(coffeeType);
const coffeeOption = getTipCoffeeOption(selectedCoffeeType); const coffeeOption = getTipCoffeeOption(selectedCoffeeType);
const returnPath = buildTipCoffeePath(selectedCoffeeType); const returnPath = buildTipCoffeePath(selectedCoffeeType);
const resolvedInitialPayChannel = const resolvedInitialPayChannel =
initialPayChannel ?? navigator.getDefaultPayChannel(); initialPayChannel ?? navigator.getDefaultPayChannel();
const { payment, paymentDispatch } = usePaymentRouteFlow({
catalog: "tip",
initialPayChannel: resolvedInitialPayChannel,
paymentType: "tip",
shouldResumePendingOrder,
});
const coffeeTiers = useMemo( const coffeeTiers = useMemo(
() => () =>
@@ -113,39 +112,6 @@ export function TipScreen({
payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy; payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy;
const isAuthLoading = !authState.hasInitialized || authState.isLoading; 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(() => { useEffect(() => {
if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) { if (payment.isLoadingPlans || payment.isCreatingOrder || payment.isPollingOrder) {
return; return;
@@ -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,
});
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { PayChannel } from "@/data/dto/payment";
export type PaymentSearchParamValue = string | string[] | undefined;
export type PaymentSearchParams = Record<string, PaymentSearchParamValue>;
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",
};
}