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
@@ -33,6 +33,27 @@ describe("navigation resolver", () => {
).toBe("/subscription?type=topup");
});
it("preserves payment analytics context through auth redirects", () => {
const subscriptionUrl = ROUTE_BUILDERS.subscription("topup", {
payChannel: "stripe",
returnTo: "chat",
analytics: {
entryPoint: "chat_unlock",
triggerReason: "ad_landing",
},
});
expect(subscriptionUrl).toBe(
"/subscription?type=topup&payChannel=stripe&returnTo=chat&analytics_entry=chat_unlock&analytics_reason=ad_landing",
);
expect(
resolveAuthenticatedNavigation({
loginStatus: "guest",
targetUrl: subscriptionUrl,
}),
).toBe(ROUTE_BUILDERS.authWithRedirect(subscriptionUrl));
});
it("allows not logged in users to enter chat for guest bootstrap", () => {
expect(
resolveRouteGuardRedirect({
+3
View File
@@ -1,4 +1,5 @@
import type { PayChannel } from "@/data/dto/payment";
import type { PaymentAnalyticsContext } from "@/lib/analytics/payment_analytics_context";
import type {
PendingChatUnlockKind,
PendingChatPromotion,
@@ -13,6 +14,7 @@ export interface OpenSubscriptionInput {
payChannel?: PayChannel;
returnTo?: AppSubscriptionReturnTo;
replace?: boolean;
analytics?: PaymentAnalyticsContext;
}
export interface StartMessageUnlockInput {
@@ -37,4 +39,5 @@ export interface OpenSubscriptionForPendingUnlockInput {
returnUrl: string;
type: AppSubscriptionType;
payChannel?: PayChannel;
analytics?: PaymentAnalyticsContext;
}
+20 -1
View File
@@ -8,6 +8,11 @@
*/
import type { PayChannel } from "@/data/dto/payment";
import {
PAYMENT_ANALYTICS_ENTRY_PARAM,
PAYMENT_ANALYTICS_REASON_PARAM,
type PaymentAnalyticsContext,
} from "@/lib/analytics/payment_analytics_context";
/** 静态路由字面量 */
export const ROUTES = {
@@ -29,11 +34,25 @@ export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
export const ROUTE_BUILDERS = {
subscription: (
type: "vip" | "topup",
options: { payChannel?: PayChannel; returnTo?: "chat" } = {},
options: {
payChannel?: PayChannel;
returnTo?: "chat";
analytics?: PaymentAnalyticsContext;
} = {},
): `/subscription?${string}` => {
const params = new URLSearchParams({ type });
if (options.payChannel) params.set("payChannel", options.payChannel);
if (options.returnTo) params.set("returnTo", options.returnTo);
if (options.analytics) {
params.set(
PAYMENT_ANALYTICS_ENTRY_PARAM,
options.analytics.entryPoint,
);
params.set(
PAYMENT_ANALYTICS_REASON_PARAM,
options.analytics.triggerReason,
);
}
return `${ROUTES.subscription}?${params.toString()}` as const;
},
authWithRedirect: (redirectTo: string): `/auth?redirect=${string}` =>
+10
View File
@@ -10,6 +10,10 @@ import {
peekSubscriptionExplicitExitUrl,
} from "@/lib/navigation/subscription_exit";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import {
behaviorAnalytics,
getDefaultPaymentAnalyticsContext,
} from "@/lib/analytics";
import { useAuthSelector } from "@/stores/auth/auth-context";
import { useUserSelector } from "@/stores/user/user-context";
@@ -88,12 +92,16 @@ export function useAppNavigator(): AppNavigator {
payChannel = getDefaultPayChannel(),
returnTo = null,
replace: shouldReplace = false,
analytics = getDefaultPaymentAnalyticsContext(type),
}: OpenSubscriptionInput): void => {
const target = ROUTE_BUILDERS.subscription(type, {
payChannel,
returnTo: returnTo ?? undefined,
analytics,
});
behaviorAnalytics.rechargeModalOpen(analytics);
const nextUrl = resolveAuthenticatedNavigation({
loginStatus,
targetUrl: target,
@@ -153,6 +161,7 @@ export function useAppNavigator(): AppNavigator {
returnUrl,
type,
payChannel = getDefaultPayChannel(),
analytics,
}: OpenSubscriptionForPendingUnlockInput): void => {
void (async () => {
await NavigationStorage.savePendingChatUnlock({
@@ -169,6 +178,7 @@ export function useAppNavigator(): AppNavigator {
type,
payChannel,
returnTo: "chat",
analytics,
});
})();
},