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
@@ -66,6 +66,9 @@ describe("subscription Tailwind components", () => {
/<button[^>]*aria-pressed="true"[^>]*>/,
)?.[0];
expect(selectedButton).toBeDefined();
expect(selectedButton).toContain(
'data-analytics-key="subscription.payment_method"',
);
expect(selectedButton).toContain("border-[#f657a0]");
expect(selectedButton).not.toContain("border-[rgba(246,87,160,0.18)]");
expect(selectedButton).toContain("bg-[#fff4f9]");
@@ -67,6 +67,8 @@ export function SubscriptionCheckoutButton({
<>
<SubscriptionCtaButton
type="button"
data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout"
disabled={disabled}
isLoading={isLoading}
onClick={handleClick}
@@ -147,6 +149,8 @@ function EzpayRedirectConfirmDialog({
</button>
<button
type="button"
data-analytics-key="subscription.external_checkout"
data-analytics-label="Continue to external checkout"
className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
@@ -40,6 +40,8 @@ export function SubscriptionCoinsOfferSection({
<button
key={plan.id}
type="button"
data-analytics-key="subscription.plan_select"
data-analytics-label="Select coin plan"
className={`${styles.row} ${selected ? styles.selected : ""}`}
aria-pressed={selected}
aria-label={`${plan.coins} Coins, ${plan.currency}${plan.price}`}
@@ -75,6 +75,8 @@ export function SubscriptionPaymentMethod({
<button
key={method.channel}
type="button"
data-analytics-key="subscription.payment_method"
data-analytics-label="Select payment method"
className={classes}
disabled={disabled}
aria-pressed={selected}
@@ -46,6 +46,8 @@ export function SubscriptionVipOfferSection({
<button
key={plan.id}
type="button"
data-analytics-key="subscription.plan_select"
data-analytics-label="Select VIP plan"
className={`${styles.planCard} ${selected ? styles.selected : ""}`}
aria-pressed={selected}
aria-label={`${plan.title}, ${plan.price} ${plan.currency}`}
@@ -3,6 +3,11 @@
import { useSearchParams } from "next/navigation";
import type { PayChannel } from "@/data/dto/payment";
import {
PAYMENT_ANALYTICS_ENTRY_PARAM,
PAYMENT_ANALYTICS_REASON_PARAM,
parsePaymentAnalyticsContext,
} from "@/lib/analytics";
import {
SubscriptionScreen,
@@ -28,6 +33,11 @@ export function SubscriptionPageClient() {
const shouldResumePendingOrder = searchParams.get("paymentReturn") === "1";
const returnTo = toReturnTo(searchParams.get("returnTo"));
const initialPayChannel = toPayChannel(searchParams.get("payChannel"));
const analyticsContext = parsePaymentAnalyticsContext({
entryPoint: searchParams.get(PAYMENT_ANALYTICS_ENTRY_PARAM),
triggerReason: searchParams.get(PAYMENT_ANALYTICS_REASON_PARAM),
subscriptionType,
});
return (
<SubscriptionScreen
@@ -35,6 +45,7 @@ export function SubscriptionPageClient() {
shouldResumePendingOrder={shouldResumePendingOrder}
returnTo={returnTo}
initialPayChannel={initialPayChannel}
analyticsContext={analyticsContext}
/>
);
}
+31 -12
View File
@@ -3,8 +3,14 @@ import { useEffect, useMemo, useRef } from "react";
import { BackButton } from "@/app/_components";
import { Checkbox, MobileShell } from "@/app/_components/core";
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import { AppConstants } from "@/core/app_constants";
import type { PayChannel } from "@/data/dto/payment";
import {
behaviorAnalytics,
getDefaultPaymentAnalyticsContext,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
import { useUserState } from "@/stores/user/user-context";
import {
@@ -34,6 +40,7 @@ export interface SubscriptionScreenProps {
shouldResumePendingOrder?: boolean;
returnTo?: "chat" | null;
initialPayChannel?: PayChannel | null;
analyticsContext?: PaymentAnalyticsContext;
}
export function SubscriptionScreen({
@@ -41,6 +48,7 @@ export function SubscriptionScreen({
shouldResumePendingOrder = false,
returnTo = null,
initialPayChannel = null,
analyticsContext: providedAnalyticsContext,
}: SubscriptionScreenProps) {
const userState = useUserState();
const countryCode = userState.currentUser?.countryCode;
@@ -64,6 +72,9 @@ export function SubscriptionScreen({
initialPayChannel: resolvedInitialPayChannel,
});
const canSubscribeVip = subscriptionType === "vip";
const analyticsContext =
providedAnalyticsContext ??
getDefaultPaymentAnalyticsContext(subscriptionType);
const vipOfferPlans = useMemo(
() => toVipOfferPlanViews(payment.plans),
@@ -73,6 +84,17 @@ export function SubscriptionScreen({
() => toCoinsOfferPlanViews(payment.plans),
[payment.plans],
);
const displayedPlans = useMemo(() => {
const displayedPlanIds = [
...(canSubscribeVip ? vipOfferPlans : []),
...directCoinsPlans,
].map((plan) => plan.id);
return displayedPlanIds.flatMap((planId) => {
const plan = payment.plans.find((item) => item.planId === planId);
return plan ? [plan] : [];
});
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
const firstRechargeOffer = useMemo(
() =>
getFirstRechargeOfferView({
@@ -95,6 +117,12 @@ export function SubscriptionScreen({
const canActivate =
selectedPlan !== null && payment.agreed && !isPaymentBusy;
const handleSelectPlan = (planId: string) => {
const plan = payment.plans.find((item) => item.planId === planId);
if (plan) behaviorAnalytics.planClick(plan, analyticsContext);
paymentDispatch({ type: "PaymentPlanSelected", planId });
};
useEffect(() => {
if (isPaymentBusy) return;
@@ -160,6 +188,7 @@ export function SubscriptionScreen({
className={styles.backSlot}
onClick={handleBackClick}
variant="soft"
analyticsKey="subscription.back"
/>
</header>
@@ -187,23 +216,13 @@ export function SubscriptionScreen({
<SubscriptionVipOfferSection
plans={vipOfferPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
onSelectPlan={handleSelectPlan}
/>
) : null}
<SubscriptionCoinsOfferSection
plans={directCoinsPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
onSelectPlan={handleSelectPlan}
/>
</div>