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 { 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", () => {
+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,
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<PaymentEvent>;
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<string | null>(null);
}: UsePendingPaymentOrderLifecycleInput): void {
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(() => {
if (
!shouldInspectPendingPaymentOrder({
+19 -18
View File
@@ -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<string, string | string[] | undefined>;
export default async function SubscriptionPage({
searchParams,
}: {
searchParams: Promise<SubscriptionSearchParams>;
searchParams: Promise<PaymentSearchParams>;
}) {
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 (
<SubscriptionScreen
subscriptionType={subscriptionType}
shouldResumePendingOrder={firstSearchParam(query.paymentReturn) === "1"}
returnTo={toReturnTo(firstSearchParam(query.returnTo))}
initialPayChannel={toPayChannel(firstSearchParam(query.payChannel))}
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
returnTo={toReturnTo(getFirstPaymentSearchParam(query.returnTo))}
initialPayChannel={paymentReturn.initialPayChannel}
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 {
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;
}
@@ -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<string | null>(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<string | null>(null);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
useState(false);
useEffect(() => {
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 {
DEFAULT_TIP_COFFEE_TYPE,
resolveTipCoffeeType,
@@ -7,31 +11,24 @@ import {
import { TipScreen } from "./tip-screen";
type TipSearchParams = Record<string, string | string[] | undefined>;
export default async function TipPage({
searchParams,
}: {
searchParams: Promise<TipSearchParams>;
searchParams: Promise<PaymentSearchParams>;
}) {
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 (
<TipScreen
coffeeType={coffeeType}
shouldResumePendingOrder={firstSearchParam(query.paymentReturn) === "1"}
initialPayChannel={toPayChannel(firstSearchParam(query.payChannel))}
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
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";
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<TipCoffeeType>(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;
@@ -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",
};
}