-
+
{state.authPanelMode === "facebook" ? (
diff --git a/src/app/auth/components/email-login-form.tsx b/src/app/auth/components/email-login-form.tsx
index 51bf1187..c837e1e7 100644
--- a/src/app/auth/components/email-login-form.tsx
+++ b/src/app/auth/components/email-login-form.tsx
@@ -98,7 +98,12 @@ export function EmailLoginForm({
/>
{/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError) */}
-
+
Login
diff --git a/src/app/auth/components/email-register-form.tsx b/src/app/auth/components/email-register-form.tsx
index 148f3da0..fb0aa0d9 100644
--- a/src/app/auth/components/email-register-form.tsx
+++ b/src/app/auth/components/email-register-form.tsx
@@ -147,7 +147,12 @@ export function EmailRegisterForm({
/>
{/* 优先级:本地校验错误 > 后端 globalError */}
-
+
Register
diff --git a/src/app/chat/components/__tests__/tailwind-components.test.tsx b/src/app/chat/components/__tests__/tailwind-components.test.tsx
index ffbc808c..88a250ba 100644
--- a/src/app/chat/components/__tests__/tailwind-components.test.tsx
+++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx
@@ -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"');
});
});
diff --git a/src/app/chat/components/browser-hint-overlay.tsx b/src/app/chat/components/browser-hint-overlay.tsx
index fd5cd53b..fcdfaa94 100644
--- a/src/app/chat/components/browser-hint-overlay.tsx
+++ b/src/app/chat/components/browser-hint-overlay.tsx
@@ -81,6 +81,8 @@ export function BrowserHintOverlay({
return (
diff --git a/src/app/sidebar/components/user-header.tsx b/src/app/sidebar/components/user-header.tsx
index ac39a1d0..168137fe 100644
--- a/src/app/sidebar/components/user-header.tsx
+++ b/src/app/sidebar/components/user-header.tsx
@@ -80,6 +80,8 @@ export function UserHeader({
{state === "guest" && (
diff --git a/src/app/splash/components/__tests__/tailwind-components.test.tsx b/src/app/splash/components/__tests__/tailwind-components.test.tsx
index 095ca0c9..c87998e6 100644
--- a/src/app/splash/components/__tests__/tailwind-components.test.tsx
+++ b/src/app/splash/components/__tests__/tailwind-components.test.tsx
@@ -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");
});
diff --git a/src/app/splash/components/splash-button.tsx b/src/app/splash/components/splash-button.tsx
index 43658a47..758a194f 100644
--- a/src/app/splash/components/splash-button.tsx
+++ b/src/app/splash/components/splash-button.tsx
@@ -11,6 +11,8 @@ export function SplashButton({ onStartChat }: SplashButtonProps) {
diff --git a/src/app/splash/components/splash-latest-message.tsx b/src/app/splash/components/splash-latest-message.tsx
index 43223293..3d9a5ee5 100644
--- a/src/app/splash/components/splash-latest-message.tsx
+++ b/src/app/splash/components/splash-latest-message.tsx
@@ -22,6 +22,8 @@ export function SplashLatestMessage({
return (
{
/]*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]");
diff --git a/src/app/subscription/components/subscription-checkout-button.tsx b/src/app/subscription/components/subscription-checkout-button.tsx
index e7c34e00..075658ab 100644
--- a/src/app/subscription/components/subscription-checkout-button.tsx
+++ b/src/app/subscription/components/subscription-checkout-button.tsx
@@ -67,6 +67,8 @@ export function SubscriptionCheckoutButton({
<>
);
}
diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx
index 7f81ed11..82ab8e92 100644
--- a/src/app/subscription/subscription-screen.tsx
+++ b/src/app/subscription/subscription-screen.tsx
@@ -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"
/>
@@ -187,23 +216,13 @@ export function SubscriptionScreen({
- paymentDispatch({
- type: "PaymentPlanSelected",
- planId,
- })
- }
+ onSelectPlan={handleSelectPlan}
/>
) : null}
- paymentDispatch({
- type: "PaymentPlanSelected",
- planId,
- })
- }
+ onSelectPlan={handleSelectPlan}
/>
diff --git a/src/app/tip/tip-checkout-button.tsx b/src/app/tip/tip-checkout-button.tsx
index fd16bc1c..bd1df32b 100644
--- a/src/app/tip/tip-checkout-button.tsx
+++ b/src/app/tip/tip-checkout-button.tsx
@@ -54,6 +54,8 @@ export function TipCheckoutButton({
<>
{
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({
{
+ delete window.CozsweetPaymentAnalytics;
+ vi.restoreAllMocks();
+});
+
+describe("behavior analytics configuration", () => {
+ it("uses production endpoints only for the production app environment", () => {
+ expect(getBehaviorAnalyticsBaseUrl("production")).toBe(
+ "https://api.banlv-ai.com",
+ );
+ expect(getBehaviorAnalyticsBaseUrl("development")).toBe(
+ "https://proapi.banlv-ai.com",
+ );
+ expect(getBehaviorAnalyticsBaseUrl("test")).toBe(
+ "https://proapi.banlv-ai.com",
+ );
+ expect(getBehaviorAnalyticsEndpoint("test")).toBe(
+ "https://proapi.banlv-ai.com/api/behavior/events",
+ );
+ expect(getBehaviorAnalyticsScriptUrl("production")).toBe(
+ "https://api.banlv-ai.com/js/cozsweet-payment-analytics.js",
+ );
+ });
+});
+
+describe("behavior analytics adapter", () => {
+ it("does nothing when the SDK is unavailable and swallows SDK errors", () => {
+ expect(() =>
+ behaviorAnalytics.planClick(plan, {
+ entryPoint: "subscription_direct",
+ triggerReason: "manual_recharge",
+ }),
+ ).not.toThrow();
+
+ const sdk = createSdkMock();
+ vi.mocked(sdk.planClick).mockImplementation(() => {
+ throw new Error("analytics unavailable");
+ });
+ window.CozsweetPaymentAnalytics = sdk;
+
+ expect(() =>
+ behaviorAnalytics.planClick(plan, {
+ entryPoint: "subscription_direct",
+ triggerReason: "manual_recharge",
+ }),
+ ).not.toThrow();
+ });
+
+ it("forwards payment context and strips checkout query parameters", () => {
+ const sdk = createSdkMock();
+ window.CozsweetPaymentAnalytics = sdk;
+
+ behaviorAnalytics.planClick(plan, {
+ entryPoint: "chat_unlock",
+ triggerReason: "insufficient_credits",
+ });
+ behaviorAnalytics.checkoutOpened({
+ orderId: "order-1",
+ plan,
+ payChannel: "stripe",
+ checkoutUrl:
+ "https://checkout.example/pay/session?client_secret=secret#payment",
+ });
+
+ expect(sdk.planClick).toHaveBeenCalledWith(
+ expect.objectContaining({ planId: "credits-100", amountCents: 499 }),
+ {
+ entryPoint: "chat_unlock",
+ triggerReason: "insufficient_credits",
+ },
+ );
+ expect(sdk.checkoutOpened).toHaveBeenCalledWith(
+ "order-1",
+ expect.objectContaining({ planId: "credits-100" }),
+ "stripe",
+ "https://checkout.example/pay/session",
+ );
+ });
+
+ it("uses a safe placeholder for invalid checkout urls", () => {
+ expect(sanitizeAnalyticsCheckoutUrl("stripe_embedded")).toBe(
+ "stripe_embedded",
+ );
+ expect(sanitizeAnalyticsCheckoutUrl("not a url")).toBe(
+ "external_checkout",
+ );
+ expect(sanitizeAnalyticsCheckoutUrl("javascript:alert('no')")).toBe(
+ "external_checkout",
+ );
+ });
+});
diff --git a/src/lib/analytics/__tests__/payment_analytics_context.test.ts b/src/lib/analytics/__tests__/payment_analytics_context.test.ts
new file mode 100644
index 00000000..f46c72e4
--- /dev/null
+++ b/src/lib/analytics/__tests__/payment_analytics_context.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getDefaultPaymentAnalyticsContext,
+ parsePaymentAnalyticsContext,
+} from "../payment_analytics_context";
+
+describe("payment analytics context", () => {
+ it("keeps supported payment entry and reason values", () => {
+ expect(
+ parsePaymentAnalyticsContext({
+ entryPoint: "chat_unlock",
+ triggerReason: "ad_landing",
+ subscriptionType: "topup",
+ }),
+ ).toEqual({
+ entryPoint: "chat_unlock",
+ triggerReason: "ad_landing",
+ });
+ });
+
+ it("falls back independently for invalid query values", () => {
+ expect(
+ parsePaymentAnalyticsContext({
+ entryPoint: "unsafe-entry",
+ triggerReason: "unsafe-reason",
+ subscriptionType: "topup",
+ }),
+ ).toEqual(getDefaultPaymentAnalyticsContext("topup"));
+
+ expect(
+ parsePaymentAnalyticsContext({
+ entryPoint: "sidebar",
+ triggerReason: null,
+ subscriptionType: "vip",
+ }),
+ ).toEqual({ entryPoint: "sidebar", triggerReason: "vip_cta" });
+ });
+});
diff --git a/src/lib/analytics/behavior_analytics.ts b/src/lib/analytics/behavior_analytics.ts
new file mode 100644
index 00000000..ebbabaa9
--- /dev/null
+++ b/src/lib/analytics/behavior_analytics.ts
@@ -0,0 +1,268 @@
+"use client";
+
+import type { PayChannel, PaymentPlan } from "@/data/dto/payment";
+
+import type {
+ PaymentAnalyticsContext,
+ PaymentAnalyticsEntryPoint,
+ PaymentAnalyticsTriggerReason,
+} from "./payment_analytics_context";
+
+export interface BehaviorAnalyticsPlan {
+ planId: string;
+ planName: string;
+ orderType: string;
+ amountCents: number;
+ currency: string;
+}
+
+export interface CozsweetBehaviorAnalyticsSdk {
+ elementClick(
+ key: string,
+ text: string,
+ metadata?: Record,
+ ): void;
+ paywallShown(
+ triggerReason: PaymentAnalyticsTriggerReason,
+ metadata?: Record,
+ ): void;
+ rechargeModalOpen(
+ entryPoint: PaymentAnalyticsEntryPoint,
+ triggerReason: PaymentAnalyticsTriggerReason,
+ metadata?: Record,
+ ): void;
+ planImpression(
+ plan: BehaviorAnalyticsPlan,
+ position: number,
+ metadata?: Record,
+ ): void;
+ planClick(
+ plan: BehaviorAnalyticsPlan,
+ metadata?: Record,
+ ): void;
+ createOrderStart(
+ plan: BehaviorAnalyticsPlan,
+ payChannel: PayChannel,
+ metadata?: Record,
+ ): void;
+ createOrderSuccess(
+ plan: BehaviorAnalyticsPlan,
+ orderId: string,
+ payChannel: PayChannel,
+ metadata?: Record,
+ ): void;
+ createOrderFailed(
+ plan: BehaviorAnalyticsPlan,
+ reason: string,
+ payChannel: PayChannel,
+ metadata?: Record,
+ ): void;
+ checkoutOpened(
+ orderId: string,
+ plan: BehaviorAnalyticsPlan,
+ payChannel: PayChannel,
+ checkoutUrl: string,
+ metadata?: Record,
+ ): void;
+ checkoutFailed(
+ orderId: string,
+ plan: BehaviorAnalyticsPlan,
+ reason: string,
+ payChannel: PayChannel,
+ metadata?: Record,
+ ): void;
+ statusPollStart(
+ orderId: string,
+ plan?: BehaviorAnalyticsPlan,
+ metadata?: Record,
+ ): void;
+ statusPollUpdate(
+ orderId: string,
+ status: string,
+ plan?: BehaviorAnalyticsPlan,
+ metadata?: Record,
+ ): void;
+ statusPollTimeout(
+ orderId: string,
+ plan?: BehaviorAnalyticsPlan,
+ metadata?: Record,
+ ): void;
+}
+
+declare global {
+ interface Window {
+ COZSWEET_ANALYTICS_ENDPOINT?: string;
+ CozsweetPaymentAnalytics?: CozsweetBehaviorAnalyticsSdk;
+ }
+}
+
+export function toBehaviorAnalyticsPlan(
+ plan: PaymentPlan,
+): BehaviorAnalyticsPlan {
+ return {
+ planId: plan.planId,
+ planName: plan.planName,
+ orderType: plan.orderType,
+ amountCents: plan.amountCents,
+ currency: plan.currency,
+ };
+}
+
+export function sanitizeAnalyticsCheckoutUrl(value: string): string {
+ if (value === "stripe_embedded") return value;
+ try {
+ const url = new URL(value);
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
+ return "external_checkout";
+ }
+ return `${url.origin}${url.pathname}`;
+ } catch {
+ return "external_checkout";
+ }
+}
+
+function invokeAnalytics(
+ callback: (analytics: CozsweetBehaviorAnalyticsSdk) => void,
+): void {
+ if (typeof window === "undefined") return;
+ const analytics = window.CozsweetPaymentAnalytics;
+ if (!analytics) return;
+ try {
+ callback(analytics);
+ } catch {
+ // Analytics must never interrupt user-facing flows.
+ }
+}
+
+export const behaviorAnalytics = {
+ elementClick(
+ key: string,
+ label: string,
+ metadata?: Record,
+ ): void {
+ invokeAnalytics((analytics) =>
+ analytics.elementClick(key, label, metadata),
+ );
+ },
+ paywallShown(
+ context: PaymentAnalyticsContext,
+ metadata?: { isVip?: boolean },
+ ): void {
+ invokeAnalytics((analytics) =>
+ analytics.paywallShown(context.triggerReason, {
+ entryPoint: context.entryPoint,
+ ...metadata,
+ }),
+ );
+ },
+ rechargeModalOpen(context: PaymentAnalyticsContext): void {
+ invokeAnalytics((analytics) =>
+ analytics.rechargeModalOpen(
+ context.entryPoint,
+ context.triggerReason,
+ ),
+ );
+ },
+ planImpression(
+ plan: PaymentPlan,
+ position: number,
+ context: PaymentAnalyticsContext,
+ ): void {
+ invokeAnalytics((analytics) =>
+ analytics.planImpression(toBehaviorAnalyticsPlan(plan), position, {
+ ...context,
+ }),
+ );
+ },
+ planClick(plan: PaymentPlan, context: PaymentAnalyticsContext): void {
+ invokeAnalytics((analytics) =>
+ analytics.planClick(toBehaviorAnalyticsPlan(plan), { ...context }),
+ );
+ },
+ createOrderStart(plan: PaymentPlan, payChannel: PayChannel): void {
+ invokeAnalytics((analytics) =>
+ analytics.createOrderStart(toBehaviorAnalyticsPlan(plan), payChannel),
+ );
+ },
+ createOrderSuccess(
+ plan: PaymentPlan,
+ orderId: string,
+ payChannel: PayChannel,
+ ): void {
+ invokeAnalytics((analytics) =>
+ analytics.createOrderSuccess(
+ toBehaviorAnalyticsPlan(plan),
+ orderId,
+ payChannel,
+ ),
+ );
+ },
+ createOrderFailed(plan: PaymentPlan, payChannel: PayChannel): void {
+ invokeAnalytics((analytics) =>
+ analytics.createOrderFailed(
+ toBehaviorAnalyticsPlan(plan),
+ "create_order_error",
+ payChannel,
+ ),
+ );
+ },
+ checkoutOpened(input: {
+ orderId: string;
+ plan: PaymentPlan;
+ payChannel: PayChannel;
+ checkoutUrl: string;
+ }): void {
+ invokeAnalytics((analytics) =>
+ analytics.checkoutOpened(
+ input.orderId,
+ toBehaviorAnalyticsPlan(input.plan),
+ input.payChannel,
+ sanitizeAnalyticsCheckoutUrl(input.checkoutUrl),
+ ),
+ );
+ },
+ checkoutFailed(input: {
+ orderId: string;
+ plan: PaymentPlan;
+ payChannel: PayChannel;
+ reason: "missing_checkout_url" | "payment_redirect_failed";
+ }): void {
+ invokeAnalytics((analytics) =>
+ analytics.checkoutFailed(
+ input.orderId,
+ toBehaviorAnalyticsPlan(input.plan),
+ input.reason,
+ input.payChannel,
+ ),
+ );
+ },
+ statusPollStart(orderId: string, plan?: PaymentPlan): void {
+ invokeAnalytics((analytics) =>
+ analytics.statusPollStart(
+ orderId,
+ plan ? toBehaviorAnalyticsPlan(plan) : undefined,
+ ),
+ );
+ },
+ statusPollUpdate(
+ orderId: string,
+ status: string,
+ plan?: PaymentPlan,
+ ): void {
+ invokeAnalytics((analytics) =>
+ analytics.statusPollUpdate(
+ orderId,
+ status,
+ plan ? toBehaviorAnalyticsPlan(plan) : undefined,
+ ),
+ );
+ },
+ statusPollTimeout(orderId: string, plan?: PaymentPlan): void {
+ invokeAnalytics((analytics) =>
+ analytics.statusPollTimeout(
+ orderId,
+ plan ? toBehaviorAnalyticsPlan(plan) : undefined,
+ ),
+ );
+ },
+};
diff --git a/src/lib/analytics/behavior_analytics_config.ts b/src/lib/analytics/behavior_analytics_config.ts
new file mode 100644
index 00000000..f8b6fcae
--- /dev/null
+++ b/src/lib/analytics/behavior_analytics_config.ts
@@ -0,0 +1,18 @@
+const PRO_ANALYTICS_BASE_URL = "https://proapi.banlv-ai.com";
+const PRODUCTION_ANALYTICS_BASE_URL = "https://api.banlv-ai.com";
+
+export function getBehaviorAnalyticsBaseUrl(
+ appEnv = process.env.NEXT_PUBLIC_APP_ENV,
+): string {
+ return appEnv === "production"
+ ? PRODUCTION_ANALYTICS_BASE_URL
+ : PRO_ANALYTICS_BASE_URL;
+}
+
+export function getBehaviorAnalyticsEndpoint(appEnv?: string): string {
+ return `${getBehaviorAnalyticsBaseUrl(appEnv)}/api/behavior/events`;
+}
+
+export function getBehaviorAnalyticsScriptUrl(appEnv?: string): string {
+ return `${getBehaviorAnalyticsBaseUrl(appEnv)}/js/cozsweet-payment-analytics.js`;
+}
diff --git a/src/lib/analytics/index.ts b/src/lib/analytics/index.ts
new file mode 100644
index 00000000..19cf88cc
--- /dev/null
+++ b/src/lib/analytics/index.ts
@@ -0,0 +1,3 @@
+export * from "./behavior_analytics";
+export * from "./behavior_analytics_config";
+export * from "./payment_analytics_context";
diff --git a/src/lib/analytics/payment_analytics_context.ts b/src/lib/analytics/payment_analytics_context.ts
new file mode 100644
index 00000000..1a7bba7d
--- /dev/null
+++ b/src/lib/analytics/payment_analytics_context.ts
@@ -0,0 +1,80 @@
+export const PAYMENT_ANALYTICS_ENTRY_PARAM = "analytics_entry";
+export const PAYMENT_ANALYTICS_REASON_PARAM = "analytics_reason";
+
+export type PaymentAnalyticsTriggerReason =
+ | "daily_chat_limit"
+ | "private_topic_limit"
+ | "insufficient_credits"
+ | "sidebar_recharge"
+ | "vip_cta"
+ | "ad_landing"
+ | "manual_recharge"
+ | "unknown";
+
+export type PaymentAnalyticsEntryPoint =
+ | "chat_input"
+ | "chat_unlock"
+ | "private_album_unlock"
+ | "private_room"
+ | "sidebar"
+ | "chat_offer_banner"
+ | "subscription_direct"
+ | "tip_page"
+ | "external_entry"
+ | "unknown";
+
+export interface PaymentAnalyticsContext {
+ entryPoint: PaymentAnalyticsEntryPoint;
+ triggerReason: PaymentAnalyticsTriggerReason;
+}
+
+const ENTRY_POINTS = new Set([
+ "chat_input",
+ "chat_unlock",
+ "private_album_unlock",
+ "private_room",
+ "sidebar",
+ "chat_offer_banner",
+ "subscription_direct",
+ "tip_page",
+ "external_entry",
+ "unknown",
+]);
+
+const TRIGGER_REASONS = new Set([
+ "daily_chat_limit",
+ "private_topic_limit",
+ "insufficient_credits",
+ "sidebar_recharge",
+ "vip_cta",
+ "ad_landing",
+ "manual_recharge",
+ "unknown",
+]);
+
+export function getDefaultPaymentAnalyticsContext(
+ type: "vip" | "topup",
+): PaymentAnalyticsContext {
+ return {
+ entryPoint: "subscription_direct",
+ triggerReason: type === "topup" ? "manual_recharge" : "vip_cta",
+ };
+}
+
+export function parsePaymentAnalyticsContext(input: {
+ entryPoint: string | null;
+ triggerReason: string | null;
+ subscriptionType: "vip" | "topup";
+}): PaymentAnalyticsContext {
+ const fallback = getDefaultPaymentAnalyticsContext(input.subscriptionType);
+ return {
+ entryPoint: ENTRY_POINTS.has(input.entryPoint as PaymentAnalyticsEntryPoint)
+ ? (input.entryPoint as PaymentAnalyticsEntryPoint)
+ : fallback.entryPoint,
+ triggerReason: TRIGGER_REASONS.has(
+ input.triggerReason as PaymentAnalyticsTriggerReason,
+ )
+ ? (input.triggerReason as PaymentAnalyticsTriggerReason)
+ : fallback.triggerReason,
+ };
+}
diff --git a/src/lib/payment/__tests__/payment_launch.test.ts b/src/lib/payment/__tests__/payment_launch.test.ts
index de18abf6..53ebabae 100644
--- a/src/lib/payment/__tests__/payment_launch.test.ts
+++ b/src/lib/payment/__tests__/payment_launch.test.ts
@@ -1,9 +1,10 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
getPaymentUrl,
getStripeClientSecret,
isEzpayPayment,
+ launchEzpayRedirect,
} from "../payment_launch";
describe("payment launch helpers", () => {
@@ -40,4 +41,22 @@ describe("payment launch helpers", () => {
}),
).toBeNull();
});
+
+ it("reports a launch failure before redirecting without an order id", async () => {
+ const onOpened = vi.fn();
+ const onFailed = vi.fn();
+
+ await launchEzpayRedirect({
+ orderId: null,
+ paymentUrl: "https://pay.example/checkout?token=secret",
+ subscriptionType: "topup",
+ onOpened,
+ onFailed,
+ });
+
+ expect(onOpened).not.toHaveBeenCalled();
+ expect(onFailed).toHaveBeenCalledWith(
+ "Missing order id before opening Ezpay.",
+ );
+ });
});
diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts
index 61d3ca96..704e8b8f 100644
--- a/src/lib/payment/payment_launch.ts
+++ b/src/lib/payment/payment_launch.ts
@@ -64,6 +64,7 @@ export interface LaunchEzpayRedirectInput {
subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType;
returnTo?: PendingPaymentReturnTo;
+ onOpened?: () => void;
onFailed: (errorMessage: string) => void;
}
@@ -73,6 +74,7 @@ export async function launchEzpayRedirect({
subscriptionType,
tipCoffeeType,
returnTo,
+ onOpened,
onFailed,
}: LaunchEzpayRedirectInput): Promise {
log.debug("[payment-launch] launchEzpayRedirect START", {
@@ -121,5 +123,10 @@ export async function launchEzpayRedirect({
subscriptionType,
paymentUrl,
});
- window.location.href = paymentUrl;
+ try {
+ window.location.href = paymentUrl;
+ onOpened?.();
+ } catch {
+ onFailed("Could not open Ezpay. Please try again.");
+ }
}
diff --git a/src/providers/behavior-analytics-scripts.tsx b/src/providers/behavior-analytics-scripts.tsx
new file mode 100644
index 00000000..00ed95f6
--- /dev/null
+++ b/src/providers/behavior-analytics-scripts.tsx
@@ -0,0 +1,15 @@
+import Script from "next/script";
+
+import { getBehaviorAnalyticsScriptUrl } from "@/lib/analytics/behavior_analytics_config";
+
+export function BehaviorAnalyticsScripts() {
+ const scriptUrl = getBehaviorAnalyticsScriptUrl();
+
+ return (
+
+ );
+}
diff --git a/src/router/__tests__/navigation-resolver.test.ts b/src/router/__tests__/navigation-resolver.test.ts
index 5c6ee8b4..271203d6 100644
--- a/src/router/__tests__/navigation-resolver.test.ts
+++ b/src/router/__tests__/navigation-resolver.test.ts
@@ -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({
diff --git a/src/router/navigation-types.ts b/src/router/navigation-types.ts
index c1c03659..9ad2c027 100644
--- a/src/router/navigation-types.ts
+++ b/src/router/navigation-types.ts
@@ -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;
}
diff --git a/src/router/routes.ts b/src/router/routes.ts
index 3123aa0a..3bcb0b4e 100644
--- a/src/router/routes.ts
+++ b/src/router/routes.ts
@@ -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}` =>
diff --git a/src/router/use-app-navigator.ts b/src/router/use-app-navigator.ts
index aa4d01b1..74882fea 100644
--- a/src/router/use-app-navigator.ts
+++ b/src/router/use-app-navigator.ts
@@ -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,
});
})();
},
diff --git a/src/stores/payment/__tests__/payment-machine.test.ts b/src/stores/payment/__tests__/payment-machine.test.ts
index 6581c40e..a6abdeb3 100644
--- a/src/stores/payment/__tests__/payment-machine.test.ts
+++ b/src/stores/payment/__tests__/payment-machine.test.ts
@@ -7,6 +7,7 @@ import {
PaymentOrderStatusResponse,
PaymentPlansResponse,
} from "@/data/dto/payment";
+import { behaviorAnalytics } from "@/lib/analytics";
import { paymentMachine } from "@/stores/payment/payment-machine";
import {
MAX_ORDER_POLLING_MS,
@@ -94,6 +95,7 @@ const tipPlan = {
function createTestPaymentMachine(
overrides: Partial<{
createOrderSpy: CreateOrderSpy;
+ createOrderError: Error;
orderStatus: "pending" | "paid" | "failed";
orderStatuses: Array<"pending" | "paid" | "failed">;
}> = {},
@@ -124,6 +126,7 @@ function createTestPaymentMachine(
CreateOrderInput
>(async ({ input }) => {
createOrderSpy(input);
+ if (overrides.createOrderError) throw overrides.createOrderError;
return CreatePaymentOrderResponse.from({
orderId: "pay_test_001",
payParams: {
@@ -152,6 +155,7 @@ function createTestPaymentMachine(
describe("paymentMachine", () => {
afterEach(() => {
vi.useRealTimers();
+ vi.restoreAllMocks();
});
it("loads plans and selects the default monthly VIP plan", async () => {
@@ -460,6 +464,18 @@ describe("paymentMachine", () => {
});
it("creates an order, polls it, and reaches paid state", async () => {
+ const createOrderStart = vi
+ .spyOn(behaviorAnalytics, "createOrderStart")
+ .mockImplementation(() => undefined);
+ const createOrderSuccess = vi
+ .spyOn(behaviorAnalytics, "createOrderSuccess")
+ .mockImplementation(() => undefined);
+ const statusPollStart = vi
+ .spyOn(behaviorAnalytics, "statusPollStart")
+ .mockImplementation(() => undefined);
+ const statusPollUpdate = vi
+ .spyOn(behaviorAnalytics, "statusPollUpdate")
+ .mockImplementation(() => undefined);
const createOrderSpy = vi.fn();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
@@ -481,6 +497,50 @@ describe("paymentMachine", () => {
expect(context.orderStatus).toBe("paid");
expect(context.orderPollingStartedAt).toBeNull();
expect(context.launchNonce).toBe(1);
+ expect(createOrderStart).toHaveBeenCalledWith(
+ expect.objectContaining({ planId: "vip_monthly" }),
+ "stripe",
+ );
+ expect(createOrderSuccess).toHaveBeenCalledWith(
+ expect.objectContaining({ planId: "vip_monthly" }),
+ "pay_test_001",
+ "stripe",
+ );
+ expect(statusPollStart).toHaveBeenCalledWith(
+ "pay_test_001",
+ expect.objectContaining({ planId: "vip_monthly" }),
+ );
+ expect(statusPollUpdate).toHaveBeenCalledWith(
+ "pay_test_001",
+ "paid",
+ expect.objectContaining({ planId: "vip_monthly" }),
+ );
+
+ actor.stop();
+ });
+
+ it("tracks order creation failures without interrupting failure handling", async () => {
+ const createOrderFailed = vi
+ .spyOn(behaviorAnalytics, "createOrderFailed")
+ .mockImplementation(() => undefined);
+ const actor = createActor(
+ createTestPaymentMachine({
+ createOrderError: new Error("backend unavailable"),
+ }),
+ ).start();
+
+ actor.send({ type: "PaymentInit" });
+ await waitFor(actor, (snapshot) => snapshot.matches("ready"));
+ actor.send({ type: "PaymentCreateOrderSubmitted" });
+ await waitFor(actor, (snapshot) => snapshot.matches("failed"));
+
+ expect(createOrderFailed).toHaveBeenCalledWith(
+ expect.objectContaining({ planId: "vip_monthly" }),
+ "stripe",
+ );
+ expect(actor.getSnapshot().context.errorMessage).toBe(
+ "backend unavailable",
+ );
actor.stop();
});
@@ -533,6 +593,9 @@ describe("paymentMachine", () => {
});
it("moves to failed state when a pending order exceeds polling timeout", async () => {
+ const statusPollTimeout = vi
+ .spyOn(behaviorAnalytics, "statusPollTimeout")
+ .mockImplementation(() => undefined);
const now = new Date("2026-06-30T00:00:00.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
@@ -555,6 +618,10 @@ describe("paymentMachine", () => {
expect(context.orderStatus).toBe("failed");
expect(context.orderPollingStartedAt).toBeNull();
expect(context.errorMessage).toBe(PAYMENT_TIMEOUT_ERROR_MESSAGE);
+ expect(statusPollTimeout).toHaveBeenCalledWith(
+ "pay_test_001",
+ expect.objectContaining({ planId: "vip_monthly" }),
+ );
actor.stop();
});
diff --git a/src/stores/payment/payment-machine.helpers.ts b/src/stores/payment/payment-machine.helpers.ts
index e81c3116..fad18e34 100644
--- a/src/stores/payment/payment-machine.helpers.ts
+++ b/src/stores/payment/payment-machine.helpers.ts
@@ -179,6 +179,7 @@ export function selectPlanState(
export function resetOrderState(): Partial {
return {
currentOrderId: null,
+ currentOrderPlanId: null,
payParams: null,
orderStatus: null,
orderPollingStartedAt: null,
diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts
index b88cd994..dfbe801a 100644
--- a/src/stores/payment/payment-machine.ts
+++ b/src/stores/payment/payment-machine.ts
@@ -3,6 +3,7 @@
*/
import { assign, setup } from "xstate";
+import { behaviorAnalytics } from "@/lib/analytics";
import type { PaymentEvent } from "./payment-events";
import { initialState, type PaymentState } from "./payment-state";
import {
@@ -28,6 +29,19 @@ export { initialState } from "./payment-state";
const POLL_DELAY_MS = 4000;
+function getSelectedPlan(context: PaymentState) {
+ return context.plans.find(
+ (plan) => plan.planId === context.selectedPlanId,
+ );
+}
+
+function getCurrentOrderPlan(context: PaymentState) {
+ if (!context.currentOrderPlanId) return undefined;
+ return context.plans.find(
+ (plan) => plan.planId === context.currentOrderPlanId,
+ );
+}
+
export const paymentMachine = setup({
types: {
context: {} as PaymentState,
@@ -192,6 +206,10 @@ export const paymentMachine = setup({
},
creatingOrder: {
+ entry: ({ context }) => {
+ const plan = getSelectedPlan(context);
+ if (plan) behaviorAnalytics.createOrderStart(plan, context.payChannel);
+ },
invoke: {
src: "createOrder",
input: ({ context }) => ({
@@ -201,20 +219,45 @@ export const paymentMachine = setup({
}),
onDone: {
target: "pollingOrder",
- actions: assign(({ context, event }) => ({
- currentOrderId: event.output.orderId,
- payParams: event.output.payParams,
- orderStatus: "pending",
- orderPollingStartedAt: Date.now(),
- errorMessage: null,
- launchNonce: context.launchNonce + 1,
- })),
+ actions: [
+ ({ context, event }) => {
+ const plan = getSelectedPlan(context);
+ if (plan) {
+ behaviorAnalytics.createOrderSuccess(
+ plan,
+ event.output.orderId,
+ context.payChannel,
+ );
+ }
+ behaviorAnalytics.statusPollStart(
+ event.output.orderId,
+ plan,
+ );
+ },
+ assign(({ context, event }) => ({
+ currentOrderId: event.output.orderId,
+ currentOrderPlanId: context.selectedPlanId,
+ payParams: event.output.payParams,
+ orderStatus: "pending",
+ orderPollingStartedAt: Date.now(),
+ errorMessage: null,
+ launchNonce: context.launchNonce + 1,
+ })),
+ ],
},
onError: {
target: "failed",
- actions: assign(({ event }) => ({
- errorMessage: toPaymentErrorMessage(event.error),
- })),
+ actions: [
+ ({ context }) => {
+ const plan = getSelectedPlan(context);
+ if (plan) {
+ behaviorAnalytics.createOrderFailed(plan, context.payChannel);
+ }
+ },
+ assign(({ event }) => ({
+ errorMessage: toPaymentErrorMessage(event.error),
+ })),
+ ],
},
},
},
@@ -229,37 +272,73 @@ export const paymentMachine = setup({
{
guard: ({ event }) => event.output.status === "paid",
target: "paid",
- actions: assign(({ event }) => ({
- orderStatus: event.output.status,
- orderPollingStartedAt: null,
- errorMessage: null,
- })),
+ actions: [
+ ({ context, event }) =>
+ behaviorAnalytics.statusPollUpdate(
+ context.currentOrderId ?? "",
+ event.output.status,
+ getCurrentOrderPlan(context),
+ ),
+ assign(({ event }) => ({
+ orderStatus: event.output.status,
+ orderPollingStartedAt: null,
+ errorMessage: null,
+ })),
+ ],
},
{
guard: ({ event }) => event.output.status === "failed",
target: "failed",
- actions: assign(({ event }) => ({
- orderStatus: event.output.status,
- orderPollingStartedAt: null,
- errorMessage: "Payment failed or was cancelled.",
- })),
+ actions: [
+ ({ context, event }) =>
+ behaviorAnalytics.statusPollUpdate(
+ context.currentOrderId ?? "",
+ event.output.status,
+ getCurrentOrderPlan(context),
+ ),
+ assign(({ event }) => ({
+ orderStatus: event.output.status,
+ orderPollingStartedAt: null,
+ errorMessage: "Payment failed or was cancelled.",
+ })),
+ ],
},
{
guard: ({ context }) =>
hasOrderPollingTimedOut(context.orderPollingStartedAt),
target: "failed",
- actions: assign({
- orderStatus: "failed",
- orderPollingStartedAt: null,
- errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE,
- }),
+ actions: [
+ ({ context, event }) => {
+ const orderId = context.currentOrderId ?? "";
+ const plan = getCurrentOrderPlan(context);
+ behaviorAnalytics.statusPollUpdate(
+ orderId,
+ event.output.status,
+ plan,
+ );
+ behaviorAnalytics.statusPollTimeout(orderId, plan);
+ },
+ assign({
+ orderStatus: "failed",
+ orderPollingStartedAt: null,
+ errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE,
+ }),
+ ],
},
{
target: "waitingForPayment",
- actions: assign(({ event }) => ({
- orderStatus: event.output.status,
- errorMessage: null,
- })),
+ actions: [
+ ({ context, event }) =>
+ behaviorAnalytics.statusPollUpdate(
+ context.currentOrderId ?? "",
+ event.output.status,
+ getCurrentOrderPlan(context),
+ ),
+ assign(({ event }) => ({
+ orderStatus: event.output.status,
+ errorMessage: null,
+ })),
+ ],
},
],
onError: {
@@ -354,13 +433,17 @@ export const paymentMachine = setup({
},
PaymentReturned: {
target: ".pollingOrder",
- actions: assign(({ event }) => ({
- currentOrderId: event.orderId,
- payParams: null,
- orderStatus: "pending",
- orderPollingStartedAt: event.createdAt ?? Date.now(),
- errorMessage: null,
- })),
+ actions: [
+ ({ event }) => behaviorAnalytics.statusPollStart(event.orderId),
+ assign(({ event }) => ({
+ currentOrderId: event.orderId,
+ currentOrderPlanId: null,
+ payParams: null,
+ orderStatus: "pending",
+ orderPollingStartedAt: event.createdAt ?? Date.now(),
+ errorMessage: null,
+ })),
+ ],
},
PaymentLaunchFailed: {
target: ".failed",
diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts
index 81249220..40876b93 100644
--- a/src/stores/payment/payment-state.ts
+++ b/src/stores/payment/payment-state.ts
@@ -18,6 +18,7 @@ export interface PaymentState {
autoRenew: boolean;
agreed: boolean;
currentOrderId: string | null;
+ currentOrderPlanId: string | null;
payParams: Record | null;
orderStatus: PaymentOrderStatus | null;
orderPollingStartedAt: number | null;
@@ -34,6 +35,7 @@ export const initialState: PaymentState = {
autoRenew: true,
agreed: true,
currentOrderId: null,
+ currentOrderPlanId: null,
payParams: null,
orderStatus: null,
orderPollingStartedAt: null,