feat(analytics): add behavior and payment funnel tracking
This commit is contained in:
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user