feat(analytics): add behavior and payment funnel tracking

This commit is contained in:
2026-07-14 16:54:13 +08:00
parent ca55723e48
commit 81d6489978
70 changed files with 1576 additions and 81 deletions
@@ -0,0 +1,82 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PaymentPlan } from "@/data/dto/payment";
import { behaviorAnalytics } from "@/lib/analytics";
import { usePaymentPlanAnalytics } from "../use-payment-plan-analytics";
const firstPlan = PaymentPlan.from({
planId: "plan-1",
planName: "Plan One",
orderType: "dol",
vipDays: null,
dolAmount: 100,
creditBalance: 100,
amountCents: 499,
originalAmountCents: null,
currency: "USD",
});
const secondPlan = PaymentPlan.from({
planId: "plan-2",
planName: "Plan Two",
orderType: "dol",
vipDays: null,
dolAmount: 250,
creditBalance: 250,
amountCents: 999,
originalAmountCents: null,
currency: "USD",
});
function Harness({ plans }: { plans: readonly PaymentPlan[] }) {
usePaymentPlanAnalytics(plans, {
entryPoint: "subscription_direct",
triggerReason: "manual_recharge",
});
return null;
}
describe("usePaymentPlanAnalytics", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
});
it("tracks displayed order once per plan during a page mount", () => {
const impression = vi
.spyOn(behaviorAnalytics, "planImpression")
.mockImplementation(() => undefined);
act(() => root.render(<Harness plans={[firstPlan]} />));
act(() => root.render(<Harness plans={[firstPlan, secondPlan]} />));
act(() => root.render(<Harness plans={[firstPlan, secondPlan]} />));
expect(impression).toHaveBeenCalledTimes(2);
expect(impression).toHaveBeenNthCalledWith(
1,
firstPlan,
1,
expect.any(Object),
);
expect(impression).toHaveBeenNthCalledWith(
2,
secondPlan,
2,
expect.any(Object),
);
});
});
+59 -5
View File
@@ -8,6 +8,7 @@ import {
isEzpayPayment,
launchEzpayRedirect,
} from "@/lib/payment/payment_launch";
import { behaviorAnalytics } from "@/lib/analytics";
import type {
PendingPaymentReturnTo,
PendingPaymentSubscriptionType,
@@ -52,6 +53,38 @@ export interface PaymentLaunchFlow {
stripeClientSecret: string | null;
}
function trackPaymentCheckoutOpened(
payment: PaymentContextState,
checkoutUrl: string,
): void {
const plan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
if (!payment.currentOrderId || !plan) return;
behaviorAnalytics.checkoutOpened({
orderId: payment.currentOrderId,
plan,
payChannel: payment.payChannel,
checkoutUrl,
});
}
function trackPaymentCheckoutFailed(
payment: PaymentContextState,
reason: "missing_checkout_url" | "payment_redirect_failed",
): void {
const plan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
if (!payment.currentOrderId || !plan) return;
behaviorAnalytics.checkoutFailed({
orderId: payment.currentOrderId,
plan,
payChannel: payment.payChannel,
reason,
});
}
export function shouldShowEzpayConfirmation({
currentOrderId,
isProduction,
@@ -87,7 +120,6 @@ export function usePaymentLaunchFlow({
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
: null;
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
return;
@@ -95,7 +127,10 @@ export function usePaymentLaunchFlow({
launchedNonceRef.current = payment.launchNonce;
const clientSecret = getStripeClientSecret(payment.payParams);
if (clientSecret) return;
if (clientSecret) {
trackPaymentCheckoutOpened(payment, "stripe_embedded");
return;
}
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
@@ -117,16 +152,29 @@ export function usePaymentLaunchFlow({
subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}),
onFailed: (errorMessage) =>
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }),
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
onFailed: (errorMessage) => {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
});
return;
}
window.location.href = paymentUrl;
try {
window.location.href = paymentUrl;
trackPaymentCheckoutOpened(payment, paymentUrl);
} catch {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: "Could not open the payment page. Please try again.",
});
}
return;
}
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: UNSUPPORTED_PAYMENT_PARAMS_MESSAGE,
@@ -135,8 +183,12 @@ export function usePaymentLaunchFlow({
log,
logScope,
payment.currentOrderId,
payment,
payment.launchNonce,
payment.payChannel,
payment.payParams,
payment.plans,
payment.selectedPlanId,
paymentDispatch,
returnTo,
subscriptionType,
@@ -183,7 +235,9 @@ export function usePaymentLaunchFlow({
subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}),
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
onFailed: (errorMessage) => {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useRef } from "react";
import type { PaymentPlan } from "@/data/dto/payment";
import {
behaviorAnalytics,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
export function usePaymentPlanAnalytics(
plans: readonly PaymentPlan[],
context: PaymentAnalyticsContext,
): void {
const trackedRef = useRef(new Set<string>());
const { entryPoint, triggerReason } = context;
useEffect(() => {
plans.forEach((plan, index) => {
const key = `${entryPoint}:${triggerReason}:${plan.planId}`;
if (trackedRef.current.has(key)) return;
trackedRef.current.add(key);
behaviorAnalytics.planImpression(plan, index + 1, {
entryPoint,
triggerReason,
});
});
}, [entryPoint, plans, triggerReason]);
}