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
@@ -14,7 +14,12 @@ describe("shared Tailwind components", () => {
<BackButton onClick={() => undefined} variant="ghost" />,
);
const darkHtml = renderToStaticMarkup(
<BackButton href="/splash" variant="dark" ariaLabel="Back to home" />,
<BackButton
href="/splash"
variant="dark"
ariaLabel="Back to home"
analyticsKey="chat.back_to_home"
/>,
);
expect(linkHtml).toContain('href="/chat"');
@@ -27,6 +32,7 @@ describe("shared Tailwind components", () => {
expect(actionHtml).toContain("hover:opacity-70");
expect(darkHtml).toContain('href="/splash"');
expect(darkHtml).toContain('aria-label="Back to home"');
expect(darkHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(darkHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(darkHtml).toContain("text-white");
});
+11 -1
View File
@@ -6,6 +6,7 @@ import Link, { type LinkProps } from "next/link";
interface BackButtonBaseProps {
className?: string;
ariaLabel?: string;
analyticsKey?: string;
iconSize?: number;
variant?: "floating" | "soft" | "ghost" | "dark";
}
@@ -41,6 +42,7 @@ export function BackButton({
onClick,
className,
ariaLabel = "Back",
analyticsKey,
iconSize = 24,
variant = "floating",
}: BackButtonProps) {
@@ -57,7 +59,13 @@ export function BackButton({
if (href) {
return (
<Link href={href} className={buttonClassName} aria-label={ariaLabel}>
<Link
href={href}
className={buttonClassName}
aria-label={ariaLabel}
data-analytics-key={analyticsKey}
data-analytics-label={ariaLabel}
>
{icon}
</Link>
);
@@ -69,6 +77,8 @@ export function BackButton({
className={buttonClassName}
onClick={onClick}
aria-label={ariaLabel}
data-analytics-key={analyticsKey}
data-analytics-label={ariaLabel}
>
{icon}
</button>
@@ -101,5 +101,9 @@ describe("core Tailwind components", () => {
expect(privateRoomHtml).toMatch(
/<button[^>]*aria-current="page"[^>]*>.*?<span>Elio Private room<\/span>/,
);
expect(chatHtml).toContain('data-analytics-key="navigation.chat"');
expect(privateRoomHtml).toContain(
'data-analytics-key="navigation.private_room"',
);
});
});
@@ -24,6 +24,8 @@ export function AppBottomNav({
<nav className={getRootClass(variant)} aria-label="Primary navigation">
<button
type="button"
data-analytics-key="navigation.chat"
data-analytics-label="Chat navigation"
className={getButtonClass(activeItem === "chat")}
aria-current={activeItem === "chat" ? "page" : undefined}
onClick={onChatClick}
@@ -33,6 +35,8 @@ export function AppBottomNav({
</button>
<button
type="button"
data-analytics-key="navigation.private_room"
data-analytics-label="Private room navigation"
className={getButtonClass(activeItem === "privateRoom")}
aria-current={activeItem === "privateRoom" ? "page" : undefined}
onClick={onPrivateRoomClick}
@@ -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]);
}
@@ -52,6 +52,7 @@ describe("auth Tailwind components", () => {
expect(html).toContain("w-full");
expect(html).toContain("gap-md");
expect(html).toContain("Login");
expect(html).toContain('data-analytics-key="auth.email_login"');
});
it("renders EmailRegisterForm with Tailwind form layout", () => {
@@ -62,6 +63,7 @@ describe("auth Tailwind components", () => {
expect(html).toContain("w-full");
expect(html).toContain("gap-md");
expect(html).toContain("Register");
expect(html).toContain('data-analytics-key="auth.email_register"');
});
it("renders AuthBackground with Tailwind layout classes", () => {
@@ -128,6 +130,7 @@ describe("auth Tailwind components", () => {
);
expect(html).toContain("Login with Facebook");
expect(html).toContain('data-analytics-key="auth.facebook_login"');
expect(html).toContain("bg-auth-surface");
expect(html).toContain("enabled:hover:brightness-[0.97]");
expect(html).toContain("Other Sign In Options");
@@ -83,6 +83,9 @@ export function AuthEmailPanel({
<button
type="button"
data-analytics-key={
mode === "login" ? "auth.open_register" : "auth.open_email_login"
}
className="self-center border-0 bg-transparent pt-md text-(length:--responsive-body) text-auth-text-primary underline underline-offset-[3px] transition-opacity duration-150 hover:opacity-80"
onClick={() => setMode((m) => (m === "login" ? "register" : "login"))}
>
@@ -93,6 +96,8 @@ export function AuthEmailPanel({
<button
type="button"
data-analytics-key="auth.open_other_options"
data-analytics-label="Open other sign-in options"
className="self-center border-0 bg-transparent py-md text-(length:--responsive-body) text-auth-text-primary underline underline-offset-[3px] transition-opacity duration-150 hover:opacity-80"
onClick={() => setShowOptions(true)}
>
@@ -63,6 +63,8 @@ export function AuthFacebookPanel({
<button
type="button"
data-analytics-key="auth.facebook_login"
data-analytics-label="Facebook login"
className="mt-md inline-flex min-h-(--auth-field-height) w-full cursor-pointer items-center justify-center gap-md rounded-full border-0 bg-auth-surface px-lg text-(length:--responsive-body) font-semibold text-auth-text-primary shadow-[0_2px_6px_rgba(0,0,0,0.06)] transition-[filter,transform,opacity] duration-150 enabled:hover:brightness-[0.97] enabled:active:scale-98 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => handleFacebook()}
disabled={isLoading}
@@ -87,6 +89,8 @@ export function AuthFacebookPanel({
<button
type="button"
data-analytics-key="auth.open_other_options"
data-analytics-label="Open other sign-in options"
className="self-center border-0 bg-transparent py-md text-(length:--responsive-body) text-auth-text-primary underline underline-offset-[3px] transition-opacity duration-150 hover:opacity-80"
onClick={() => setShowOptions(true)}
>
@@ -52,6 +52,8 @@ export function AuthOtherOptionsDialog({
{mode === "email" ? (
<AuthSocialButton
data-analytics-key="auth.facebook_login"
data-analytics-label="Facebook login"
icon={<AuthProviderIcon provider="facebook" />}
onClick={() => {
onClose();
@@ -64,6 +66,8 @@ export function AuthOtherOptionsDialog({
{showNonProductionEmailOption ? (
<AuthSocialButton
data-analytics-key="auth.open_email_login"
data-analytics-label="Email login option"
icon={<MdEmail size={20} color="var(--color-auth-text-primary)" />}
onClick={() => {
onClose();
@@ -78,6 +82,8 @@ export function AuthOtherOptionsDialog({
<div className="w-full">{googleSlot}</div>
) : (
<AuthSocialButton
data-analytics-key="auth.google_login"
data-analytics-label="Google login"
icon={<AuthProviderIcon provider="google" />}
onClick={() => {
onClose();
+1 -1
View File
@@ -29,7 +29,7 @@ export function AuthPanel() {
return (
<div className="relative flex min-h-0 w-full flex-auto flex-col">
<BackButton onClick={handleBack} />
<BackButton onClick={handleBack} analyticsKey="auth.back" />
{state.authPanelMode === "facebook" ? (
<AuthFacebookPanel onSwitchToEmail={switchToEmail} />
+6 -1
View File
@@ -98,7 +98,12 @@ export function EmailLoginForm({
/>
{/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError */}
<AuthErrorMessage message={validationError ?? globalError ?? null} />
<AuthPrimaryButton type="submit" isLoading={isLoading}>
<AuthPrimaryButton
type="submit"
isLoading={isLoading}
data-analytics-key="auth.email_login"
data-analytics-label="Email login"
>
Login
</AuthPrimaryButton>
</form>
@@ -147,7 +147,12 @@ export function EmailRegisterForm({
/>
{/* 优先级:本地校验错误 > 后端 globalError */}
<AuthErrorMessage message={validationError ?? globalError ?? null} />
<AuthPrimaryButton type="submit" isLoading={isLoading}>
<AuthPrimaryButton
type="submit"
isLoading={isLoading}
data-analytics-key="auth.email_register"
data-analytics-label="Email register"
>
Register
</AuthPrimaryButton>
</form>
@@ -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 {
+9
View File
@@ -1,6 +1,9 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { SerwistProvider } from "@serwist/turbopack/react";
import { SessionProvider } from "@/providers/session-provider";
import { BehaviorAnalyticsScripts } from "@/providers/behavior-analytics-scripts";
import { getBehaviorAnalyticsEndpoint } from "@/lib/analytics/behavior_analytics_config";
import { geistSans, geistMono, athelas } from "@/core/fonts";
import "./globals.css";
@@ -43,6 +46,12 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${athelas.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Script id="cozsweet-analytics-config" strategy="beforeInteractive">
{`window.COZSWEET_ANALYTICS_ENDPOINT=${JSON.stringify(
getBehaviorAnalyticsEndpoint(),
)}`}
</Script>
<BehaviorAnalyticsScripts />
{/* NextAuth SessionProvider:为所有客户端组件提供 useSession() 上下文 */}
<SessionProvider>
<RootProviders>
@@ -61,6 +61,9 @@ describe("PrivateAlbumCard", () => {
expect(html).toContain("8 photos");
expect(html).toContain("A quiet morning by the water.");
expect(html).toContain('aria-label="Open 8 private album photos"');
expect(html).toContain(
'data-analytics-key="private_album.open_gallery"',
);
});
it("uses the backend first image as the locked cover", () => {
@@ -80,6 +83,7 @@ describe("PrivateAlbumCard", () => {
);
expect(html).toContain("Only for you.");
expect(html).toContain("320 credits");
expect(html).toContain('data-analytics-key="private_album.unlock"');
});
it("renders an empty cover when an unlocked album has no image", () => {
@@ -61,6 +61,8 @@ export function PrivateAlbumCard({
{isLocked ? (
<button
type="button"
data-analytics-key="private_album.unlock"
data-analytics-label="Unlock private album"
className={styles.lockedPreview}
disabled={isUnlocking}
onClick={onUnlock}
@@ -93,6 +95,8 @@ export function PrivateAlbumCard({
) : firstImageUrl ? (
<button
type="button"
data-analytics-key="private_album.open_gallery"
data-analytics-label="Open private album gallery"
className={styles.mediaCover}
onClick={onOpenGallery}
aria-label={`Open ${photoCount} private album photos`}
@@ -38,6 +38,8 @@ export function UnlockConfirmDialog({
</button>
<button
type="button"
data-analytics-key="private_album.unlock_confirm"
data-analytics-label="Confirm private album unlock"
className={styles.dialogPrimary}
disabled={isUnlocking}
onClick={onConfirm}
+9 -1
View File
@@ -109,7 +109,13 @@ export function PrivateRoomScreen() {
navigator.openAuth(ROUTES.privateRoom);
return;
}
navigator.openSubscription({ type: "topup" });
navigator.openSubscription({
type: "topup",
analytics: {
entryPoint: "private_room",
triggerReason: "vip_cta",
},
});
};
const handleOpenGallery = (albumId: string) => {
@@ -171,6 +177,8 @@ export function PrivateRoomScreen() {
<button
type="button"
data-analytics-key="private_room.primary_cta"
data-analytics-label="Open private room top up"
className={styles.primaryCta}
onClick={handleTopUpClick}
>
+12 -1
View File
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth";
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
import { behaviorAnalytics } from "@/lib/analytics";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { AuthEvent } from "@/stores/auth/auth-events";
@@ -84,7 +85,17 @@ export function usePrivateRoomUnlockPaywallNavigation({
if (isPrivateRoomAuthRequired(loginStatus)) {
navigator.openAuth(ROUTES.privateRoom);
} else {
navigator.openSubscription({ type: "topup" });
behaviorAnalytics.paywallShown({
entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits",
});
navigator.openSubscription({
type: "topup",
analytics: {
entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits",
},
});
}
roomDispatch({ type: "PrivateRoomUnlockPaywallConsumed" });
}, [loginStatus, navigator, roomDispatch, unlockPaywallRequest]);
@@ -45,6 +45,8 @@ export function SidebarWalletCard({
<button
type="button"
data-analytics-key="sidebar.coins_rules"
data-analytics-label="Open coin usage rules"
className={styles.rulesButton}
onClick={onRulesClick}
>
@@ -56,13 +58,21 @@ export function SidebarWalletCard({
{onActivateVip ? (
<button
type="button"
data-analytics-key="sidebar.activate_vip"
data-analytics-label="Activate VIP"
className={styles.activateButton}
onClick={onActivateVip}
>
Activate VIP
</button>
) : null}
<button type="button" className={styles.topUpButton} onClick={onTopUp}>
<button
type="button"
data-analytics-key="sidebar.topup"
data-analytics-label="Top up credits"
className={styles.topUpButton}
onClick={onTopUp}
>
Top Up
</button>
</div>
@@ -80,6 +80,8 @@ export function UserHeader({
{state === "guest" && (
<button
type="button"
data-analytics-key="sidebar.login"
data-analytics-label="Open login"
className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[linear-gradient(90deg,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] px-(--spacing-lg,16px) py-[clamp(5px,1.111vw,6px)] text-(length:clamp(12px,2.593vw,14px)) font-semibold text-(--color-text-primary,#ffffff) hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--color-accent,#f84d96)"
onClick={onLoginClick}
aria-label="Log in"
+24 -3
View File
@@ -46,7 +46,11 @@ export function SidebarScreen() {
<div className={styles.bgOrbTwo} aria-hidden="true" />
<div className={styles.topBar}>
<BackButton href={ROUTES.chat} variant="soft" />
<BackButton
href={ROUTES.chat}
variant="soft"
analyticsKey="sidebar.back_to_chat"
/>
</div>
<section className={`${styles.userSlot} ${styles.revealOne}`}>
@@ -68,10 +72,25 @@ export function SidebarScreen() {
dailyFreePrivateRemaining={view.wallet.dailyFreePrivateRemaining}
onActivateVip={
view.canActivateVip
? () => navigator.openSubscription({ type: "vip" })
? () =>
navigator.openSubscription({
type: "vip",
analytics: {
entryPoint: "sidebar",
triggerReason: "vip_cta",
},
})
: undefined
}
onTopUp={() => navigator.openSubscription({ type: "topup" })}
onTopUp={() =>
navigator.openSubscription({
type: "topup",
analytics: {
entryPoint: "sidebar",
triggerReason: "sidebar_recharge",
},
})
}
onRulesClick={() => navigator.push(ROUTES.coinsRules)}
/>
</section>
@@ -103,6 +122,8 @@ export function SidebarScreen() {
<button
type="button"
data-analytics-key="sidebar.logout"
data-analytics-label="Log out"
className={styles.logoutCard}
onClick={handleLogoutClick}
>
@@ -46,6 +46,7 @@ describe("splash Tailwind components", () => {
expect(html).toContain("active:enabled:brightness-90");
expect(html).toContain("touch-manipulation");
expect(html).toContain("Start Chatting");
expect(html).toContain('data-analytics-key="splash.start_chat"');
expect(html).not.toContain("disabled=\"");
expect(html).not.toContain("animate-spin");
});
@@ -11,6 +11,8 @@ export function SplashButton({ onStartChat }: SplashButtonProps) {
<div className="z-2 flex w-full flex-row items-center justify-center gap-[clamp(var(--spacing-md,12px),4.815vw,var(--spacing-26,26px))] px-[clamp(var(--spacing-sm,8px),2.963vw,var(--spacing-lg,16px))]">
<button
type="button"
data-analytics-key="splash.start_chat"
data-analytics-label="Start chatting"
onClick={onStartChat}
className="inline-flex min-h-(--responsive-control-height,48px) w-full max-w-120 touch-manipulation flex-auto cursor-pointer items-center justify-center rounded-(--radius-full,999px) border-0 bg-[linear-gradient(to_right,var(--color-button-gradient-start),var(--color-button-gradient-end))] px-[clamp(var(--spacing-lg,16px),5.556vw,30px)] font-bold italic text-white shadow-[0_0_10px_rgba(248,89,168,0.3)] transition-[filter,box-shadow,transform] duration-150 ease-out [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-white enabled:hover:-translate-y-px enabled:hover:brightness-105 enabled:hover:shadow-[0_8px_22px_rgba(248,89,168,0.42)] active:enabled:translate-y-0.5 active:enabled:scale-96 active:enabled:brightness-90 active:enabled:shadow-[0_2px_6px_rgba(248,89,168,0.24)] motion-reduce:transition-none motion-reduce:active:enabled:transform-none disabled:cursor-not-allowed disabled:opacity-70"
>
@@ -22,6 +22,8 @@ export function SplashLatestMessage({
return (
<button
type="button"
data-analytics-key="splash.latest_message"
data-analytics-label="Open latest message"
className={styles.card}
onClick={onOpenChat}
aria-label="Open latest message from Elio"
-5
View File
@@ -18,11 +18,6 @@ import {
import { useSplashLatestMessage } from "./hooks/use-splash-latest-message";
import styles from "./components/splash-screen.module.css";
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
if (typeof window !== "undefined") {
pwaUtil.prepareInstallPrompt();
}
export function SplashScreen() {
const navigator = useAppNavigator();
const authState = useAuthState();
@@ -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>
+4
View File
@@ -54,6 +54,8 @@ export function TipCheckoutButton({
<>
<button
type="button"
data-analytics-key="tip.checkout"
data-analytics-label="Buy coffee tip"
className={styles.checkoutButton}
disabled={disabled || isLoading}
onClick={onOrder}
@@ -127,6 +129,8 @@ function EzpayRedirectConfirmDialog({
</button>
<button
type="button"
data-analytics-key="tip.external_checkout"
data-analytics-label="Continue to external checkout"
className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
+16
View File
@@ -7,7 +7,12 @@ import { ArrowLeft, Heart, Sparkles } from "lucide-react";
import { CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core";
import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle";
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import type { PayChannel } from "@/data/dto/payment";
import {
behaviorAnalytics,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
import {
buildTipCoffeePath,
DEFAULT_TIP_COFFEE_TYPE,
@@ -29,6 +34,11 @@ import {
} from "./tip-screen.helpers";
import styles from "./tip-screen.module.css";
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
entryPoint: "tip_page",
triggerReason: "ad_landing",
};
export interface TipScreenProps {
coffeeType?: TipCoffeeType;
shouldResumePendingOrder?: boolean;
@@ -55,6 +65,7 @@ export function TipScreen({
[coffeeType, payment.plans],
);
const priceLabel = formatTipPrice(coffeePlan, coffeeType);
usePaymentPlanAnalytics(coffeePlan ? [coffeePlan] : [], TIP_ANALYTICS_CONTEXT);
const isPaymentBusy =
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
const canCreateOrder =
@@ -136,6 +147,9 @@ export function TipScreen({
const handleOrder = () => {
if (isAuthLoading) return;
if (coffeePlan) {
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
}
if (!isRealLoginStatus(authState.loginStatus)) {
navigator.openAuth(returnPath);
return;
@@ -163,6 +177,8 @@ export function TipScreen({
<header className={styles.header}>
<button
type="button"
data-analytics-key="tip.back"
data-analytics-label="Go back from tip"
className={styles.backButton}
aria-label="Go back"
onClick={handleBack}