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
@@ -96,6 +96,7 @@ describe("chat Tailwind components", () => {
);
expect(activeHtml).toContain('aria-label="Send message"');
expect(activeHtml).toContain('data-analytics-key="chat.send_message"');
expect(activeHtml).toContain("size-(--chat-send-button-size,40px)");
expect(activeHtml).toContain(
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
@@ -167,6 +168,8 @@ describe("chat Tailwind components", () => {
expect(memberHtml).toContain('href="/splash"');
expect(memberHtml).toContain('aria-label="Back to home"');
expect(memberHtml).toContain('aria-label="Menu"');
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)");
});
@@ -264,6 +267,7 @@ describe("chat Tailwind components", () => {
expect(html).toContain("We found 3 locked messages");
expect(html).toContain("Could not unlock history.");
expect(html).toContain("Unlocking...");
expect(html).toContain('data-analytics-key="chat.unlock_history"');
expect(html).toContain("text-(--color-pwa-button-text,#ffffff)");
expect(html).toContain("disabled");
});
@@ -302,6 +306,7 @@ describe("chat Tailwind components", () => {
expect(html).toContain("text-(--color-pwa-button-text,#ffffff)");
expect(html).toContain("bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)]");
expect(html).toContain("Top up now");
expect(html).toContain('data-analytics-key="chat.topup"');
});
});
@@ -81,6 +81,8 @@ export function BrowserHintOverlay({
return (
<button
type="button"
data-analytics-key="chat.external_browser_hint"
data-analytics-label="External browser hint"
className={[
OVERLAY_BASE_CLASS_NAME,
isExpanded ? OVERLAY_EXPANDED_CLASS_NAME : OVERLAY_COLLAPSED_CLASS_NAME,
+5
View File
@@ -25,6 +25,8 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
{isGuest ? (
<button
type="button"
data-analytics-key="chat.open_signup"
data-analytics-label="Open sign up"
className="flex w-full cursor-pointer items-center justify-center gap-(--spacing-sm,8px) border-0 bg-(--color-accent,#f84d96) px-(--spacing-md,12px) py-(--spacing-sm,8px) text-center text-(length:--responsive-caption,var(--font-size-sm,12px)) font-medium text-white"
onClick={() => navigator.openAuth(ROUTES.chat)}
aria-label="Sign up to unlock more features"
@@ -46,10 +48,13 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
href={ROUTES.splash}
variant="dark"
ariaLabel="Back to home"
analyticsKey="chat.back_to_home"
/>
<button
type="button"
data-analytics-key="chat.open_menu"
data-analytics-label="Open chat menu"
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-(--radius-full,999px) border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={() => navigator.push(ROUTES.sidebar)}
aria-label="Menu"
@@ -69,6 +69,8 @@ export function ChatInsufficientCreditsBanner({
) : null}
<button
type="button"
data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits"
className="min-h-(--responsive-control-height,48px) w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(135deg,#ffb8e0_0%,#f57ec0_100%)] px-(--responsive-card-padding-lg,24px) text-(length:--responsive-card-title,18px) font-bold text-white shadow-[0_2px_8px_rgba(0,0,0,0.15)] transition-[box-shadow,transform] duration-150 hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)] active:scale-98"
onClick={handleClick}
aria-label={ctaLabel}
@@ -29,6 +29,8 @@ export function ChatSendButton({
return (
<button
type="button"
data-analytics-key="chat.send_message"
data-analytics-label="Send message"
className={[
"flex aspect-square size-(--chat-send-button-size,40px) shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-(--color-button-gradient-end,#fc69df) text-white transition-[background,transform] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
isActive
@@ -23,6 +23,8 @@ export function FirstRechargeOfferBanner({
<section className={styles.banner} aria-label="First recharge offer">
<button
type="button"
data-analytics-key="chat.first_recharge_offer"
data-analytics-label="Open first recharge offer"
className={styles.contentButton}
onClick={onClick}
>
@@ -68,6 +68,8 @@ export function FullscreenImageViewer({
</button>
<button
type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock private image"
className={styles.unlockButton}
disabled={isUnlockingImagePaywall || !onUnlockImagePaywall}
onClick={onUnlockImagePaywall}
@@ -53,6 +53,8 @@ export function HistoryUnlockDialog({
</button>
<button
type="button"
data-analytics-key="chat.unlock_history"
data-analytics-label="Unlock chat history"
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-(--radius-bottom-sheet,28px) border-0 bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-(length:--responsive-body,var(--font-size-lg,16px)) font-semibold text-(--color-pwa-button-text,#ffffff) disabled:cursor-not-allowed disabled:opacity-72"
onClick={onConfirm}
disabled={isLoading}
@@ -72,6 +72,8 @@ export function InsufficientCreditsDialog({
</button>
<button
type="button"
data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits"
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-(--radius-bottom-sheet,28px) border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-(--color-pwa-button-text,#ffffff) shadow-[0_6px_14px_rgba(246,87,160,0.28)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={onConfirm}
>
@@ -33,6 +33,8 @@ export function LockedImageMessageCard({
</p>
<button
type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock private image"
className="mt-(--spacing-md,12px) w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(90deg,#ff67e0,#ff52a2)] px-(--spacing-md,12px) py-[clamp(9px,1.852vw,10px)] text-(length:--responsive-body,14px) font-bold text-white disabled:cursor-not-allowed disabled:opacity-65 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
disabled={isUnlocking || !onUnlock}
onClick={onUnlock}
@@ -32,6 +32,8 @@ export function PrivateMessageCard({
</p>
<button
type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock private message"
className="mt-(--spacing-md,12px) w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(90deg,#ff67e0,#ff52a2)] px-(--spacing-md,12px) py-[clamp(9px,1.852vw,10px)] text-(length:--responsive-body,14px) font-bold text-white disabled:cursor-not-allowed disabled:opacity-65 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
disabled={isUnlocking || !onUnlock}
onClick={onUnlock}
+2
View File
@@ -124,6 +124,8 @@ export function VoiceBubble({
</p>
<button
type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock voice message"
className={styles.unlockButton}
disabled={isUnlocking || !onUnlock}
onClick={onUnlock}
@@ -1,6 +1,9 @@
"use client";
import { useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth";
import { behaviorAnalytics } from "@/lib/analytics";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
@@ -33,6 +36,27 @@ export function useChatMessageLimitBanner({
const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip);
const view = getInsufficientCreditsMessageLimitView(loginStatus);
const visible = shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
});
const trackedVisibleRef = useRef(false);
useEffect(() => {
if (!visible) {
trackedVisibleRef.current = false;
return;
}
if (trackedVisibleRef.current) return;
trackedVisibleRef.current = true;
behaviorAnalytics.paywallShown(
{
entryPoint: "chat_input",
triggerReason: "insufficient_credits",
},
{ isVip },
);
}, [isVip, visible]);
function unlock(): void {
if (view.action === "auth") {
@@ -43,15 +67,16 @@ export function useChatMessageLimitBanner({
navigator.openSubscription({
type: getInsufficientCreditsSubscriptionType(isVip),
returnTo: "chat",
analytics: {
entryPoint: "chat_input",
triggerReason: "insufficient_credits",
},
});
}
return {
...view,
visible: shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
}),
visible,
unlock,
};
}
@@ -1,9 +1,10 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { shallowEqual } from "@xstate/react";
import type { ChatLockType } from "@/data/schemas/chat";
import { behaviorAnalytics } from "@/lib/analytics";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
@@ -60,6 +61,7 @@ export function useChatUnlockNavigationFlow({
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip);
const trackedPaywallRef = useRef<string | null>(null);
const chatState = useChatSelector(
(state) => ({
historyLoaded: state.context.historyLoaded,
@@ -77,6 +79,22 @@ export function useChatUnlockNavigationFlow({
enabled,
});
useEffect(() => {
if (!unlockPaywallRequest) return;
const requestKey = `${unlockPaywallRequest.displayMessageId}:${unlockPaywallRequest.clientLockId ?? ""}`;
if (trackedPaywallRef.current === requestKey) return;
trackedPaywallRef.current = requestKey;
behaviorAnalytics.paywallShown(
{
entryPoint: "chat_unlock",
triggerReason: unlockPaywallRequest.promotion
? "ad_landing"
: "insufficient_credits",
},
{ isVip },
);
}, [isVip, unlockPaywallRequest]);
useEffect(() => {
if (!enabled) return;
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
@@ -172,6 +190,12 @@ export function useChatUnlockNavigationFlow({
promotion: unlockPaywallRequest.promotion,
returnUrl,
type: getInsufficientCreditsSubscriptionType(isVip),
analytics: {
entryPoint: "chat_unlock",
triggerReason: unlockPaywallRequest.promotion
? "ad_landing"
: "insufficient_credits",
},
});
}
@@ -139,7 +139,14 @@ export function useFirstRechargeOfferBanner({
}
function claim(): void {
navigator.openSubscription({ type: "vip", returnTo: "chat" });
navigator.openSubscription({
type: "vip",
returnTo: "chat",
analytics: {
entryPoint: "chat_offer_banner",
triggerReason: "vip_cta",
},
});
}
return {