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
@@ -0,0 +1,267 @@
# Cozsweet 全站行为埋点前端接入
> 发布状态(2026-07-14):自动页面/按钮采集 SDK 已部署 `pro` 预发,生产仍是旧版支付专项 SDK。前端应先接 `pro` 完成联调;生产自动采集要等 unified 下一次生产镜像发布后再启用。
## 1. 目标
前端接入一次后,自动统计:
- 用户访问了哪个页面。
- Next.js 站内路由切换。
- 所有原生按钮、链接和带 `role="button"` 的控件点击。
-`data-analytics-key` 的自定义可点击组件。
支付漏斗另外上报明确业务事件,统计付费墙原因、套餐曝光、套餐点击、创建订单、打开支付页和最终支付结果。
## 2. 环境地址
| 环境 | SDK | 事件接口 |
| --- | --- | --- |
| pro 预发 | `https://proapi.banlv-ai.com/js/cozsweet-payment-analytics.js` | `https://proapi.banlv-ai.com/api/behavior/events` |
| production 生产 | `https://api.banlv-ai.com/js/cozsweet-payment-analytics.js` | `https://api.banlv-ai.com/api/behavior/events` |
## 3. Next.js 根布局接入
只在根布局加载一次。预发必须先指定预发事件地址,否则 SDK 默认发送到生产。
```tsx
import Script from 'next/script'
export function BehaviorAnalyticsScripts() {
const isPro = process.env.NEXT_PUBLIC_APP_ENV === 'pro'
const apiBase = isPro
? 'https://proapi.banlv-ai.com'
: 'https://api.banlv-ai.com'
return (
<>
<Script id="cozsweet-analytics-config" strategy="beforeInteractive">
{`window.COZSWEET_ANALYTICS_ENDPOINT=${JSON.stringify(
`${apiBase}/api/behavior/events`,
)}`}
</Script>
<Script
src={`${apiBase}/js/cozsweet-payment-analytics.js`}
strategy="afterInteractive"
/>
</>
)
}
```
SDK 会自动生成并持久化:
- `anonymousId`:未登录用户标识。
- `sessionId`30 分钟会话标识。
- `pagePath/pageTitle/referrer`
- `siteHost/pageUrl/UTM`
登录成功或恢复会话后设置 Token,让事件同时绑定 `userId`
```ts
window.CozsweetPaymentAnalytics?.setToken(accessToken)
```
## 4. 所有按钮点击
以下元素会自动上报 `eventType=element_click`,前端不需要在每个 `onClick` 里手工请求接口:
- `button`
- `a[href]`
- `[role="button"]`
- `[role="link"]`
- `input[type="button|submit|reset"]`
- `[data-analytics-key]`
重要按钮必须提供稳定且不随语言变化的 `data-analytics-key`
```tsx
<button data-analytics-key="chat.send_message">Send</button>
<button data-analytics-key="recharge.open" data-analytics-label="Open recharge">
Recharge
</button>
<div
role="button"
data-analytics-key="private_album.unlock"
data-analytics-label="Unlock private album"
onClick={unlockAlbum}
/>
```
`div onClick` 如果没有 `role="button"`,必须添加 `data-analytics-key`,否则浏览器无法可靠识别它是按钮。
不需要统计的区域:
```tsx
<div data-analytics-ignore>
{/* 此节点及其子节点不自动记录点击 */}
</div>
```
程序触发、没有真实 DOM 点击时可以手工记录:
```ts
window.CozsweetPaymentAnalytics?.elementClick(
'chat.retry_generation',
'Retry generation',
{ messageId },
)
```
不要把聊天正文、邮箱、手机号、Token 或支付参数放进 `data-analytics-key`、标签或 metadata。
## 5. 支付漏斗必须手工上报
通用按钮点击只能知道“点了某个按钮”,不能知道套餐价格、订单 ID 和进入充值页的原因。以下事件必须在支付业务代码对应步骤调用。
```ts
const analytics = window.CozsweetPaymentAnalytics
analytics?.paywallShown('daily_chat_limit', {
entryPoint: 'chat_input',
isVip: false,
})
analytics?.rechargeModalOpen('chat_input', 'daily_chat_limit')
plans.forEach((plan, index) => {
analytics?.planImpression(plan, index + 1)
})
analytics?.planClick(plan, {
entryPoint: 'paywall_modal',
triggerReason: 'daily_chat_limit',
})
analytics?.createOrderStart(plan, payChannel)
try {
const response = await createOrder({
planId: plan.planId,
payChannel,
autoRenew,
})
const data = response.data
const orderId = data.orderId
const checkoutUrl = analytics?.extractCheckoutUrl(data)
analytics?.createOrderSuccess(plan, orderId, payChannel)
if (checkoutUrl) {
analytics?.checkoutOpened(orderId, plan, payChannel, checkoutUrl)
window.location.href = checkoutUrl
} else {
analytics?.checkoutFailed(
orderId,
plan,
'missing_checkout_url',
payChannel,
)
}
} catch (error) {
analytics?.createOrderFailed(
plan,
error instanceof Error ? error.message : 'create_order_error',
payChannel,
)
}
```
轮询订单状态:
```ts
analytics?.statusPollStart(orderId, plan)
analytics?.statusPollUpdate(orderId, data.status, plan, {
expiresAt: data.expiresAt,
expiresInSeconds: data.expiresInSeconds,
})
// 前端主动放弃轮询且订单仍未进入终态时调用
analytics?.statusPollTimeout(orderId, plan)
```
## 6. 固定事件名
| 步骤 | 事件名 | 必要 metadata |
| --- | --- | --- |
| 触发付费墙 | `paywall_shown` | `triggerReason`, `entryPoint` |
| 打开充值页 | `recharge_modal_open` | `triggerReason`, `entryPoint` |
| 套餐曝光 | `payment_plan_impression` | `planId`, `amountCents`, `currency`, `position` |
| 点击套餐 | `payment_plan_click` | `planId`, `amountCents`, `currency` |
| 开始建单 | `payment_create_order_start` | `planId`, `payChannel` |
| 建单成功 | `payment_create_order_success` | `planId`, `orderId`, `payChannel` |
| 建单失败 | `payment_create_order_failed` | `planId`, `reason`, `payChannel` |
| 打开支付页 | `payment_checkout_opened` | `planId`, `orderId`, `payChannel` |
| 支付页缺失/打开失败 | `payment_checkout_failed` | `orderId`, `reason` |
| 支付状态 | `payment_status_paid/failed/expired` | `orderId`, `status` |
| 轮询超时 | `payment_status_poll_timeout` | `orderId` |
`triggerReason` 使用固定值:
- `daily_chat_limit`
- `private_topic_limit`
- `insufficient_credits`
- `sidebar_recharge`
- `vip_cta`
- `ad_landing`
- `manual_recharge`
- `unknown`
不要继续使用已经废弃的 `weekly_chat_limit` 作为新事件原因;当前免费聊天口径是每日额度。
## 7. 请求格式
SDK 最终批量发送:
```http
POST /api/behavior/events
Content-Type: application/json
Authorization: Bearer <TOKEN> // Token
```
```json
{
"events": [
{
"eventType": "element_click",
"eventName": "element_click",
"anonymousId": "browser-uuid",
"sessionId": "session-uuid",
"pagePath": "/chat",
"pageTitle": "Chat",
"elementKey": "recharge.open",
"elementText": "Recharge",
"elementTag": "button",
"metadata": {
"siteHost": "cozsweet.com",
"pageUrl": "https://cozsweet.com/chat"
}
}
]
}
```
成功响应:
```json
{"ok": true, "inserted": 1}
```
埋点失败不能阻塞聊天、解锁或支付流程。SDK 已吞掉网络异常,业务代码不要 `await` 埋点后再继续支付。
## 8. 联调验收
1. 打开浏览器 Network,筛选 `behavior/events`
2. 首次打开页面应看到 `page_view`
3. Next.js 路由切换后应再次看到新 `pagePath``page_view`
4. 点击任意按钮应看到 `element_click` 和稳定 `elementKey`
5. 从付费墙点击一个套餐,依次检查专用支付事件及 `planId`
6. 建单成功后必须看到同一 `orderId` 出现在建单、支付页和状态事件中。
7. 接口响应必须为 `ok=true``inserted>0`
8. Manager 选择同一日期范围,确认数据出现;统计不是实时推送时,刷新页面即可。
## 9. 当前线上为 0 的原因
截至 2026-07-14,线上 `cozsweet.com` 页面及加载的前端 JS 中没有找到本 SDK,也没有找到支付漏斗事件代码。数据库近期大部分 `element_click/page_view` 来自 Manager 后台自己的追踪,而不是 Cozsweet 用户端。后端只能自动记录建单开始/成功/失败,无法替前端知道付费墙是否展示、套餐是否曝光、用户点击了哪个套餐、支付页是否真正打开,因此这些步骤显示 0 是符合当前数据事实的。
+2 -2
View File
@@ -6,10 +6,10 @@ NEXTAUTH_URL=https://cozsweet.com
NEXTAUTH_URL_INTERNAL=http://localhost:3000 NEXTAUTH_URL_INTERNAL=http://localhost:3000
# 后端 API 基础 URL(生产环境) # 后端 API 基础 URL(生产环境)
NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com NEXT_PUBLIC_API_BASE_URL=https://api.banlv-ai.com
# WebSocket URL(生产环境) # WebSocket URL(生产环境)
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws NEXT_PUBLIC_WS_BASE_URL=wss://api.banlv-ai.com/ws
# Stripe 支付(publishable key 可暴露在浏览器端) # Stripe 支付(publishable key 可暴露在浏览器端)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Te8ybEJDtUGxQHouKlEMfMuCZWifzlhBIReeKvANOtyCXS4zL0SMTWJXiFHPKz7azpF6OnIKXUPFSLs82fevVjr00w1bw5mvl
@@ -14,7 +14,12 @@ describe("shared Tailwind components", () => {
<BackButton onClick={() => undefined} variant="ghost" />, <BackButton onClick={() => undefined} variant="ghost" />,
); );
const darkHtml = renderToStaticMarkup( 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"'); expect(linkHtml).toContain('href="/chat"');
@@ -27,6 +32,7 @@ describe("shared Tailwind components", () => {
expect(actionHtml).toContain("hover:opacity-70"); expect(actionHtml).toContain("hover:opacity-70");
expect(darkHtml).toContain('href="/splash"'); expect(darkHtml).toContain('href="/splash"');
expect(darkHtml).toContain('aria-label="Back to home"'); 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("bg-[rgba(13,11,20,0.7)]");
expect(darkHtml).toContain("text-white"); expect(darkHtml).toContain("text-white");
}); });
+11 -1
View File
@@ -6,6 +6,7 @@ import Link, { type LinkProps } from "next/link";
interface BackButtonBaseProps { interface BackButtonBaseProps {
className?: string; className?: string;
ariaLabel?: string; ariaLabel?: string;
analyticsKey?: string;
iconSize?: number; iconSize?: number;
variant?: "floating" | "soft" | "ghost" | "dark"; variant?: "floating" | "soft" | "ghost" | "dark";
} }
@@ -41,6 +42,7 @@ export function BackButton({
onClick, onClick,
className, className,
ariaLabel = "Back", ariaLabel = "Back",
analyticsKey,
iconSize = 24, iconSize = 24,
variant = "floating", variant = "floating",
}: BackButtonProps) { }: BackButtonProps) {
@@ -57,7 +59,13 @@ export function BackButton({
if (href) { if (href) {
return ( 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} {icon}
</Link> </Link>
); );
@@ -69,6 +77,8 @@ export function BackButton({
className={buttonClassName} className={buttonClassName}
onClick={onClick} onClick={onClick}
aria-label={ariaLabel} aria-label={ariaLabel}
data-analytics-key={analyticsKey}
data-analytics-label={ariaLabel}
> >
{icon} {icon}
</button> </button>
@@ -101,5 +101,9 @@ describe("core Tailwind components", () => {
expect(privateRoomHtml).toMatch( expect(privateRoomHtml).toMatch(
/<button[^>]*aria-current="page"[^>]*>.*?<span>Elio Private room<\/span>/, /<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"> <nav className={getRootClass(variant)} aria-label="Primary navigation">
<button <button
type="button" type="button"
data-analytics-key="navigation.chat"
data-analytics-label="Chat navigation"
className={getButtonClass(activeItem === "chat")} className={getButtonClass(activeItem === "chat")}
aria-current={activeItem === "chat" ? "page" : undefined} aria-current={activeItem === "chat" ? "page" : undefined}
onClick={onChatClick} onClick={onChatClick}
@@ -33,6 +35,8 @@ export function AppBottomNav({
</button> </button>
<button <button
type="button" type="button"
data-analytics-key="navigation.private_room"
data-analytics-label="Private room navigation"
className={getButtonClass(activeItem === "privateRoom")} className={getButtonClass(activeItem === "privateRoom")}
aria-current={activeItem === "privateRoom" ? "page" : undefined} aria-current={activeItem === "privateRoom" ? "page" : undefined}
onClick={onPrivateRoomClick} 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, isEzpayPayment,
launchEzpayRedirect, launchEzpayRedirect,
} from "@/lib/payment/payment_launch"; } from "@/lib/payment/payment_launch";
import { behaviorAnalytics } from "@/lib/analytics";
import type { import type {
PendingPaymentReturnTo, PendingPaymentReturnTo,
PendingPaymentSubscriptionType, PendingPaymentSubscriptionType,
@@ -52,6 +53,38 @@ export interface PaymentLaunchFlow {
stripeClientSecret: string | null; 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({ export function shouldShowEzpayConfirmation({
currentOrderId, currentOrderId,
isProduction, isProduction,
@@ -87,7 +120,6 @@ export function usePaymentLaunchFlow({
const ezpayPaymentUrl = payment.payParams const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams) ? getPaymentUrl(payment.payParams)
: null; : null;
useEffect(() => { useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
return; return;
@@ -95,7 +127,10 @@ export function usePaymentLaunchFlow({
launchedNonceRef.current = payment.launchNonce; launchedNonceRef.current = payment.launchNonce;
const clientSecret = getStripeClientSecret(payment.payParams); const clientSecret = getStripeClientSecret(payment.payParams);
if (clientSecret) return; if (clientSecret) {
trackPaymentCheckoutOpened(payment, "stripe_embedded");
return;
}
const paymentUrl = getPaymentUrl(payment.payParams); const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) { if (paymentUrl) {
@@ -117,16 +152,29 @@ export function usePaymentLaunchFlow({
subscriptionType, subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}), ...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}), ...(returnTo ? { returnTo } : {}),
onFailed: (errorMessage) => onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }), onFailed: (errorMessage) => {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
}); });
return; 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; return;
} }
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({ paymentDispatch({
type: "PaymentLaunchFailed", type: "PaymentLaunchFailed",
errorMessage: UNSUPPORTED_PAYMENT_PARAMS_MESSAGE, errorMessage: UNSUPPORTED_PAYMENT_PARAMS_MESSAGE,
@@ -135,8 +183,12 @@ export function usePaymentLaunchFlow({
log, log,
logScope, logScope,
payment.currentOrderId, payment.currentOrderId,
payment,
payment.launchNonce, payment.launchNonce,
payment.payChannel,
payment.payParams, payment.payParams,
payment.plans,
payment.selectedPlanId,
paymentDispatch, paymentDispatch,
returnTo, returnTo,
subscriptionType, subscriptionType,
@@ -183,7 +235,9 @@ export function usePaymentLaunchFlow({
subscriptionType, subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}), ...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}), ...(returnTo ? { returnTo } : {}),
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
onFailed: (errorMessage) => { onFailed: (errorMessage) => {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
setIsConfirmingEzpay(false); setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }); 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("w-full");
expect(html).toContain("gap-md"); expect(html).toContain("gap-md");
expect(html).toContain("Login"); expect(html).toContain("Login");
expect(html).toContain('data-analytics-key="auth.email_login"');
}); });
it("renders EmailRegisterForm with Tailwind form layout", () => { it("renders EmailRegisterForm with Tailwind form layout", () => {
@@ -62,6 +63,7 @@ describe("auth Tailwind components", () => {
expect(html).toContain("w-full"); expect(html).toContain("w-full");
expect(html).toContain("gap-md"); expect(html).toContain("gap-md");
expect(html).toContain("Register"); expect(html).toContain("Register");
expect(html).toContain('data-analytics-key="auth.email_register"');
}); });
it("renders AuthBackground with Tailwind layout classes", () => { it("renders AuthBackground with Tailwind layout classes", () => {
@@ -128,6 +130,7 @@ describe("auth Tailwind components", () => {
); );
expect(html).toContain("Login with Facebook"); expect(html).toContain("Login with Facebook");
expect(html).toContain('data-analytics-key="auth.facebook_login"');
expect(html).toContain("bg-auth-surface"); expect(html).toContain("bg-auth-surface");
expect(html).toContain("enabled:hover:brightness-[0.97]"); expect(html).toContain("enabled:hover:brightness-[0.97]");
expect(html).toContain("Other Sign In Options"); expect(html).toContain("Other Sign In Options");
@@ -83,6 +83,9 @@ export function AuthEmailPanel({
<button <button
type="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" 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"))} onClick={() => setMode((m) => (m === "login" ? "register" : "login"))}
> >
@@ -93,6 +96,8 @@ export function AuthEmailPanel({
<button <button
type="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" 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)} onClick={() => setShowOptions(true)}
> >
@@ -63,6 +63,8 @@ export function AuthFacebookPanel({
<button <button
type="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" 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()} onClick={() => handleFacebook()}
disabled={isLoading} disabled={isLoading}
@@ -87,6 +89,8 @@ export function AuthFacebookPanel({
<button <button
type="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" 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)} onClick={() => setShowOptions(true)}
> >
@@ -52,6 +52,8 @@ export function AuthOtherOptionsDialog({
{mode === "email" ? ( {mode === "email" ? (
<AuthSocialButton <AuthSocialButton
data-analytics-key="auth.facebook_login"
data-analytics-label="Facebook login"
icon={<AuthProviderIcon provider="facebook" />} icon={<AuthProviderIcon provider="facebook" />}
onClick={() => { onClick={() => {
onClose(); onClose();
@@ -64,6 +66,8 @@ export function AuthOtherOptionsDialog({
{showNonProductionEmailOption ? ( {showNonProductionEmailOption ? (
<AuthSocialButton <AuthSocialButton
data-analytics-key="auth.open_email_login"
data-analytics-label="Email login option"
icon={<MdEmail size={20} color="var(--color-auth-text-primary)" />} icon={<MdEmail size={20} color="var(--color-auth-text-primary)" />}
onClick={() => { onClick={() => {
onClose(); onClose();
@@ -78,6 +82,8 @@ export function AuthOtherOptionsDialog({
<div className="w-full">{googleSlot}</div> <div className="w-full">{googleSlot}</div>
) : ( ) : (
<AuthSocialButton <AuthSocialButton
data-analytics-key="auth.google_login"
data-analytics-label="Google login"
icon={<AuthProviderIcon provider="google" />} icon={<AuthProviderIcon provider="google" />}
onClick={() => { onClick={() => {
onClose(); onClose();
+1 -1
View File
@@ -29,7 +29,7 @@ export function AuthPanel() {
return ( return (
<div className="relative flex min-h-0 w-full flex-auto flex-col"> <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" ? ( {state.authPanelMode === "facebook" ? (
<AuthFacebookPanel onSwitchToEmail={switchToEmail} /> <AuthFacebookPanel onSwitchToEmail={switchToEmail} />
+6 -1
View File
@@ -98,7 +98,12 @@ export function EmailLoginForm({
/> />
{/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError */} {/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError */}
<AuthErrorMessage message={validationError ?? globalError ?? null} /> <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 Login
</AuthPrimaryButton> </AuthPrimaryButton>
</form> </form>
@@ -147,7 +147,12 @@ export function EmailRegisterForm({
/> />
{/* 优先级:本地校验错误 > 后端 globalError */} {/* 优先级:本地校验错误 > 后端 globalError */}
<AuthErrorMessage message={validationError ?? globalError ?? null} /> <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 Register
</AuthPrimaryButton> </AuthPrimaryButton>
</form> </form>
@@ -96,6 +96,7 @@ describe("chat Tailwind components", () => {
); );
expect(activeHtml).toContain('aria-label="Send message"'); 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("size-(--chat-send-button-size,40px)");
expect(activeHtml).toContain( expect(activeHtml).toContain(
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]", "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('href="/splash"');
expect(memberHtml).toContain('aria-label="Back to home"'); expect(memberHtml).toContain('aria-label="Back to home"');
expect(memberHtml).toContain('aria-label="Menu"'); 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("bg-[rgba(13,11,20,0.7)]");
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)"); 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("We found 3 locked messages");
expect(html).toContain("Could not unlock history."); expect(html).toContain("Could not unlock history.");
expect(html).toContain("Unlocking..."); 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("text-(--color-pwa-button-text,#ffffff)");
expect(html).toContain("disabled"); expect(html).toContain("disabled");
}); });
@@ -302,6 +306,7 @@ describe("chat Tailwind components", () => {
expect(html).toContain("text-(--color-pwa-button-text,#ffffff)"); expect(html).toContain("text-(--color-pwa-button-text,#ffffff)");
expect(html).toContain("bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)]"); expect(html).toContain("bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)]");
expect(html).toContain("Top up now"); expect(html).toContain("Top up now");
expect(html).toContain('data-analytics-key="chat.topup"');
}); });
}); });
@@ -81,6 +81,8 @@ export function BrowserHintOverlay({
return ( return (
<button <button
type="button" type="button"
data-analytics-key="chat.external_browser_hint"
data-analytics-label="External browser hint"
className={[ className={[
OVERLAY_BASE_CLASS_NAME, OVERLAY_BASE_CLASS_NAME,
isExpanded ? OVERLAY_EXPANDED_CLASS_NAME : OVERLAY_COLLAPSED_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 ? ( {isGuest ? (
<button <button
type="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" 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)} onClick={() => navigator.openAuth(ROUTES.chat)}
aria-label="Sign up to unlock more features" aria-label="Sign up to unlock more features"
@@ -46,10 +48,13 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
href={ROUTES.splash} href={ROUTES.splash}
variant="dark" variant="dark"
ariaLabel="Back to home" ariaLabel="Back to home"
analyticsKey="chat.back_to_home"
/> />
<button <button
type="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]" 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)} onClick={() => navigator.push(ROUTES.sidebar)}
aria-label="Menu" aria-label="Menu"
@@ -69,6 +69,8 @@ export function ChatInsufficientCreditsBanner({
) : null} ) : null}
<button <button
type="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" 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} onClick={handleClick}
aria-label={ctaLabel} aria-label={ctaLabel}
@@ -29,6 +29,8 @@ export function ChatSendButton({
return ( return (
<button <button
type="button" type="button"
data-analytics-key="chat.send_message"
data-analytics-label="Send message"
className={[ 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))]", "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 isActive
@@ -23,6 +23,8 @@ export function FirstRechargeOfferBanner({
<section className={styles.banner} aria-label="First recharge offer"> <section className={styles.banner} aria-label="First recharge offer">
<button <button
type="button" type="button"
data-analytics-key="chat.first_recharge_offer"
data-analytics-label="Open first recharge offer"
className={styles.contentButton} className={styles.contentButton}
onClick={onClick} onClick={onClick}
> >
@@ -68,6 +68,8 @@ export function FullscreenImageViewer({
</button> </button>
<button <button
type="button" type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock private image"
className={styles.unlockButton} className={styles.unlockButton}
disabled={isUnlockingImagePaywall || !onUnlockImagePaywall} disabled={isUnlockingImagePaywall || !onUnlockImagePaywall}
onClick={onUnlockImagePaywall} onClick={onUnlockImagePaywall}
@@ -53,6 +53,8 @@ export function HistoryUnlockDialog({
</button> </button>
<button <button
type="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" 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} onClick={onConfirm}
disabled={isLoading} disabled={isLoading}
@@ -72,6 +72,8 @@ export function InsufficientCreditsDialog({
</button> </button>
<button <button
type="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]" 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} onClick={onConfirm}
> >
@@ -33,6 +33,8 @@ export function LockedImageMessageCard({
</p> </p>
<button <button
type="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]" 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} disabled={isUnlocking || !onUnlock}
onClick={onUnlock} onClick={onUnlock}
@@ -32,6 +32,8 @@ export function PrivateMessageCard({
</p> </p>
<button <button
type="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]" 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} disabled={isUnlocking || !onUnlock}
onClick={onUnlock} onClick={onUnlock}
+2
View File
@@ -124,6 +124,8 @@ export function VoiceBubble({
</p> </p>
<button <button
type="button" type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock voice message"
className={styles.unlockButton} className={styles.unlockButton}
disabled={isUnlocking || !onUnlock} disabled={isUnlocking || !onUnlock}
onClick={onUnlock} onClick={onUnlock}
@@ -1,6 +1,9 @@
"use client"; "use client";
import { useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import { behaviorAnalytics } from "@/lib/analytics";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state"; import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
@@ -33,6 +36,27 @@ export function useChatMessageLimitBanner({
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip); const isVip = useUserSelector((state) => state.context.isVip);
const view = getInsufficientCreditsMessageLimitView(loginStatus); 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 { function unlock(): void {
if (view.action === "auth") { if (view.action === "auth") {
@@ -43,15 +67,16 @@ export function useChatMessageLimitBanner({
navigator.openSubscription({ navigator.openSubscription({
type: getInsufficientCreditsSubscriptionType(isVip), type: getInsufficientCreditsSubscriptionType(isVip),
returnTo: "chat", returnTo: "chat",
analytics: {
entryPoint: "chat_input",
triggerReason: "insufficient_credits",
},
}); });
} }
return { return {
...view, ...view,
visible: shouldShowMessageLimitBanner({ visible,
upgradePromptVisible,
upgradeReason,
}),
unlock, unlock,
}; };
} }
@@ -1,9 +1,10 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { shallowEqual } from "@xstate/react"; import { shallowEqual } from "@xstate/react";
import type { ChatLockType } from "@/data/schemas/chat"; import type { ChatLockType } from "@/data/schemas/chat";
import { behaviorAnalytics } from "@/lib/analytics";
import { import {
consumePendingChatUnlock, consumePendingChatUnlock,
peekPendingChatUnlock, peekPendingChatUnlock,
@@ -60,6 +61,7 @@ export function useChatUnlockNavigationFlow({
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput { }: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip); const isVip = useUserSelector((state) => state.context.isVip);
const trackedPaywallRef = useRef<string | null>(null);
const chatState = useChatSelector( const chatState = useChatSelector(
(state) => ({ (state) => ({
historyLoaded: state.context.historyLoaded, historyLoaded: state.context.historyLoaded,
@@ -77,6 +79,22 @@ export function useChatUnlockNavigationFlow({
enabled, 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(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return; if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
@@ -172,6 +190,12 @@ export function useChatUnlockNavigationFlow({
promotion: unlockPaywallRequest.promotion, promotion: unlockPaywallRequest.promotion,
returnUrl, returnUrl,
type: getInsufficientCreditsSubscriptionType(isVip), type: getInsufficientCreditsSubscriptionType(isVip),
analytics: {
entryPoint: "chat_unlock",
triggerReason: unlockPaywallRequest.promotion
? "ad_landing"
: "insufficient_credits",
},
}); });
} }
@@ -139,7 +139,14 @@ export function useFirstRechargeOfferBanner({
} }
function claim(): void { function claim(): void {
navigator.openSubscription({ type: "vip", returnTo: "chat" }); navigator.openSubscription({
type: "vip",
returnTo: "chat",
analytics: {
entryPoint: "chat_offer_banner",
triggerReason: "vip_cta",
},
});
} }
return { return {
+9
View File
@@ -1,6 +1,9 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { SerwistProvider } from "@serwist/turbopack/react"; import { SerwistProvider } from "@serwist/turbopack/react";
import { SessionProvider } from "@/providers/session-provider"; 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 { geistSans, geistMono, athelas } from "@/core/fonts";
import "./globals.css"; import "./globals.css";
@@ -43,6 +46,12 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${athelas.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} ${athelas.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <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() 上下文 */} {/* NextAuth SessionProvider:为所有客户端组件提供 useSession() 上下文 */}
<SessionProvider> <SessionProvider>
<RootProviders> <RootProviders>
@@ -61,6 +61,9 @@ describe("PrivateAlbumCard", () => {
expect(html).toContain("8 photos"); expect(html).toContain("8 photos");
expect(html).toContain("A quiet morning by the water."); expect(html).toContain("A quiet morning by the water.");
expect(html).toContain('aria-label="Open 8 private album photos"'); 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", () => { 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("Only for you.");
expect(html).toContain("320 credits"); 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", () => { it("renders an empty cover when an unlocked album has no image", () => {
@@ -61,6 +61,8 @@ export function PrivateAlbumCard({
{isLocked ? ( {isLocked ? (
<button <button
type="button" type="button"
data-analytics-key="private_album.unlock"
data-analytics-label="Unlock private album"
className={styles.lockedPreview} className={styles.lockedPreview}
disabled={isUnlocking} disabled={isUnlocking}
onClick={onUnlock} onClick={onUnlock}
@@ -93,6 +95,8 @@ export function PrivateAlbumCard({
) : firstImageUrl ? ( ) : firstImageUrl ? (
<button <button
type="button" type="button"
data-analytics-key="private_album.open_gallery"
data-analytics-label="Open private album gallery"
className={styles.mediaCover} className={styles.mediaCover}
onClick={onOpenGallery} onClick={onOpenGallery}
aria-label={`Open ${photoCount} private album photos`} aria-label={`Open ${photoCount} private album photos`}
@@ -38,6 +38,8 @@ export function UnlockConfirmDialog({
</button> </button>
<button <button
type="button" type="button"
data-analytics-key="private_album.unlock_confirm"
data-analytics-label="Confirm private album unlock"
className={styles.dialogPrimary} className={styles.dialogPrimary}
disabled={isUnlocking} disabled={isUnlocking}
onClick={onConfirm} onClick={onConfirm}
+9 -1
View File
@@ -109,7 +109,13 @@ export function PrivateRoomScreen() {
navigator.openAuth(ROUTES.privateRoom); navigator.openAuth(ROUTES.privateRoom);
return; return;
} }
navigator.openSubscription({ type: "topup" }); navigator.openSubscription({
type: "topup",
analytics: {
entryPoint: "private_room",
triggerReason: "vip_cta",
},
});
}; };
const handleOpenGallery = (albumId: string) => { const handleOpenGallery = (albumId: string) => {
@@ -171,6 +177,8 @@ export function PrivateRoomScreen() {
<button <button
type="button" type="button"
data-analytics-key="private_room.primary_cta"
data-analytics-label="Open private room top up"
className={styles.primaryCta} className={styles.primaryCta}
onClick={handleTopUpClick} 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 type { LoginStatus } from "@/data/dto/auth";
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap"; import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
import { behaviorAnalytics } from "@/lib/analytics";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import type { AuthEvent } from "@/stores/auth/auth-events"; import type { AuthEvent } from "@/stores/auth/auth-events";
@@ -84,7 +85,17 @@ export function usePrivateRoomUnlockPaywallNavigation({
if (isPrivateRoomAuthRequired(loginStatus)) { if (isPrivateRoomAuthRequired(loginStatus)) {
navigator.openAuth(ROUTES.privateRoom); navigator.openAuth(ROUTES.privateRoom);
} else { } 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" }); roomDispatch({ type: "PrivateRoomUnlockPaywallConsumed" });
}, [loginStatus, navigator, roomDispatch, unlockPaywallRequest]); }, [loginStatus, navigator, roomDispatch, unlockPaywallRequest]);
@@ -45,6 +45,8 @@ export function SidebarWalletCard({
<button <button
type="button" type="button"
data-analytics-key="sidebar.coins_rules"
data-analytics-label="Open coin usage rules"
className={styles.rulesButton} className={styles.rulesButton}
onClick={onRulesClick} onClick={onRulesClick}
> >
@@ -56,13 +58,21 @@ export function SidebarWalletCard({
{onActivateVip ? ( {onActivateVip ? (
<button <button
type="button" type="button"
data-analytics-key="sidebar.activate_vip"
data-analytics-label="Activate VIP"
className={styles.activateButton} className={styles.activateButton}
onClick={onActivateVip} onClick={onActivateVip}
> >
Activate VIP Activate VIP
</button> </button>
) : null} ) : 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 Top Up
</button> </button>
</div> </div>
@@ -80,6 +80,8 @@ export function UserHeader({
{state === "guest" && ( {state === "guest" && (
<button <button
type="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)" 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} onClick={onLoginClick}
aria-label="Log in" 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.bgOrbTwo} aria-hidden="true" />
<div className={styles.topBar}> <div className={styles.topBar}>
<BackButton href={ROUTES.chat} variant="soft" /> <BackButton
href={ROUTES.chat}
variant="soft"
analyticsKey="sidebar.back_to_chat"
/>
</div> </div>
<section className={`${styles.userSlot} ${styles.revealOne}`}> <section className={`${styles.userSlot} ${styles.revealOne}`}>
@@ -68,10 +72,25 @@ export function SidebarScreen() {
dailyFreePrivateRemaining={view.wallet.dailyFreePrivateRemaining} dailyFreePrivateRemaining={view.wallet.dailyFreePrivateRemaining}
onActivateVip={ onActivateVip={
view.canActivateVip view.canActivateVip
? () => navigator.openSubscription({ type: "vip" }) ? () =>
navigator.openSubscription({
type: "vip",
analytics: {
entryPoint: "sidebar",
triggerReason: "vip_cta",
},
})
: undefined : undefined
} }
onTopUp={() => navigator.openSubscription({ type: "topup" })} onTopUp={() =>
navigator.openSubscription({
type: "topup",
analytics: {
entryPoint: "sidebar",
triggerReason: "sidebar_recharge",
},
})
}
onRulesClick={() => navigator.push(ROUTES.coinsRules)} onRulesClick={() => navigator.push(ROUTES.coinsRules)}
/> />
</section> </section>
@@ -103,6 +122,8 @@ export function SidebarScreen() {
<button <button
type="button" type="button"
data-analytics-key="sidebar.logout"
data-analytics-label="Log out"
className={styles.logoutCard} className={styles.logoutCard}
onClick={handleLogoutClick} onClick={handleLogoutClick}
> >
@@ -46,6 +46,7 @@ describe("splash Tailwind components", () => {
expect(html).toContain("active:enabled:brightness-90"); expect(html).toContain("active:enabled:brightness-90");
expect(html).toContain("touch-manipulation"); expect(html).toContain("touch-manipulation");
expect(html).toContain("Start Chatting"); expect(html).toContain("Start Chatting");
expect(html).toContain('data-analytics-key="splash.start_chat"');
expect(html).not.toContain("disabled=\""); expect(html).not.toContain("disabled=\"");
expect(html).not.toContain("animate-spin"); 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))]"> <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 <button
type="button" type="button"
data-analytics-key="splash.start_chat"
data-analytics-label="Start chatting"
onClick={onStartChat} 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" 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 ( return (
<button <button
type="button" type="button"
data-analytics-key="splash.latest_message"
data-analytics-label="Open latest message"
className={styles.card} className={styles.card}
onClick={onOpenChat} onClick={onOpenChat}
aria-label="Open latest message from Elio" aria-label="Open latest message from Elio"
-5
View File
@@ -18,11 +18,6 @@ import {
import { useSplashLatestMessage } from "./hooks/use-splash-latest-message"; import { useSplashLatestMessage } from "./hooks/use-splash-latest-message";
import styles from "./components/splash-screen.module.css"; import styles from "./components/splash-screen.module.css";
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
if (typeof window !== "undefined") {
pwaUtil.prepareInstallPrompt();
}
export function SplashScreen() { export function SplashScreen() {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const authState = useAuthState(); const authState = useAuthState();
@@ -66,6 +66,9 @@ describe("subscription Tailwind components", () => {
/<button[^>]*aria-pressed="true"[^>]*>/, /<button[^>]*aria-pressed="true"[^>]*>/,
)?.[0]; )?.[0];
expect(selectedButton).toBeDefined(); expect(selectedButton).toBeDefined();
expect(selectedButton).toContain(
'data-analytics-key="subscription.payment_method"',
);
expect(selectedButton).toContain("border-[#f657a0]"); expect(selectedButton).toContain("border-[#f657a0]");
expect(selectedButton).not.toContain("border-[rgba(246,87,160,0.18)]"); expect(selectedButton).not.toContain("border-[rgba(246,87,160,0.18)]");
expect(selectedButton).toContain("bg-[#fff4f9]"); expect(selectedButton).toContain("bg-[#fff4f9]");
@@ -67,6 +67,8 @@ export function SubscriptionCheckoutButton({
<> <>
<SubscriptionCtaButton <SubscriptionCtaButton
type="button" type="button"
data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout"
disabled={disabled} disabled={disabled}
isLoading={isLoading} isLoading={isLoading}
onClick={handleClick} onClick={handleClick}
@@ -147,6 +149,8 @@ function EzpayRedirectConfirmDialog({
</button> </button>
<button <button
type="button" type="button"
data-analytics-key="subscription.external_checkout"
data-analytics-label="Continue to external checkout"
className={`${dialogStyles.button} ${dialogStyles.primary}`} className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming} disabled={isConfirming}
onClick={onConfirm} onClick={onConfirm}
@@ -40,6 +40,8 @@ export function SubscriptionCoinsOfferSection({
<button <button
key={plan.id} key={plan.id}
type="button" type="button"
data-analytics-key="subscription.plan_select"
data-analytics-label="Select coin plan"
className={`${styles.row} ${selected ? styles.selected : ""}`} className={`${styles.row} ${selected ? styles.selected : ""}`}
aria-pressed={selected} aria-pressed={selected}
aria-label={`${plan.coins} Coins, ${plan.currency}${plan.price}`} aria-label={`${plan.coins} Coins, ${plan.currency}${plan.price}`}
@@ -75,6 +75,8 @@ export function SubscriptionPaymentMethod({
<button <button
key={method.channel} key={method.channel}
type="button" type="button"
data-analytics-key="subscription.payment_method"
data-analytics-label="Select payment method"
className={classes} className={classes}
disabled={disabled} disabled={disabled}
aria-pressed={selected} aria-pressed={selected}
@@ -46,6 +46,8 @@ export function SubscriptionVipOfferSection({
<button <button
key={plan.id} key={plan.id}
type="button" type="button"
data-analytics-key="subscription.plan_select"
data-analytics-label="Select VIP plan"
className={`${styles.planCard} ${selected ? styles.selected : ""}`} className={`${styles.planCard} ${selected ? styles.selected : ""}`}
aria-pressed={selected} aria-pressed={selected}
aria-label={`${plan.title}, ${plan.price} ${plan.currency}`} aria-label={`${plan.title}, ${plan.price} ${plan.currency}`}
@@ -3,6 +3,11 @@
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import type { PayChannel } from "@/data/dto/payment"; import type { PayChannel } from "@/data/dto/payment";
import {
PAYMENT_ANALYTICS_ENTRY_PARAM,
PAYMENT_ANALYTICS_REASON_PARAM,
parsePaymentAnalyticsContext,
} from "@/lib/analytics";
import { import {
SubscriptionScreen, SubscriptionScreen,
@@ -28,6 +33,11 @@ export function SubscriptionPageClient() {
const shouldResumePendingOrder = searchParams.get("paymentReturn") === "1"; const shouldResumePendingOrder = searchParams.get("paymentReturn") === "1";
const returnTo = toReturnTo(searchParams.get("returnTo")); const returnTo = toReturnTo(searchParams.get("returnTo"));
const initialPayChannel = toPayChannel(searchParams.get("payChannel")); 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 ( return (
<SubscriptionScreen <SubscriptionScreen
@@ -35,6 +45,7 @@ export function SubscriptionPageClient() {
shouldResumePendingOrder={shouldResumePendingOrder} shouldResumePendingOrder={shouldResumePendingOrder}
returnTo={returnTo} returnTo={returnTo}
initialPayChannel={initialPayChannel} initialPayChannel={initialPayChannel}
analyticsContext={analyticsContext}
/> />
); );
} }
+31 -12
View File
@@ -3,8 +3,14 @@ import { useEffect, useMemo, useRef } from "react";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { Checkbox, MobileShell } from "@/app/_components/core"; import { Checkbox, MobileShell } from "@/app/_components/core";
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import { AppConstants } from "@/core/app_constants"; import { AppConstants } from "@/core/app_constants";
import type { PayChannel } from "@/data/dto/payment"; import type { PayChannel } from "@/data/dto/payment";
import {
behaviorAnalytics,
getDefaultPaymentAnalyticsContext,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
import { useUserState } from "@/stores/user/user-context"; import { useUserState } from "@/stores/user/user-context";
import { import {
@@ -34,6 +40,7 @@ export interface SubscriptionScreenProps {
shouldResumePendingOrder?: boolean; shouldResumePendingOrder?: boolean;
returnTo?: "chat" | null; returnTo?: "chat" | null;
initialPayChannel?: PayChannel | null; initialPayChannel?: PayChannel | null;
analyticsContext?: PaymentAnalyticsContext;
} }
export function SubscriptionScreen({ export function SubscriptionScreen({
@@ -41,6 +48,7 @@ export function SubscriptionScreen({
shouldResumePendingOrder = false, shouldResumePendingOrder = false,
returnTo = null, returnTo = null,
initialPayChannel = null, initialPayChannel = null,
analyticsContext: providedAnalyticsContext,
}: SubscriptionScreenProps) { }: SubscriptionScreenProps) {
const userState = useUserState(); const userState = useUserState();
const countryCode = userState.currentUser?.countryCode; const countryCode = userState.currentUser?.countryCode;
@@ -64,6 +72,9 @@ export function SubscriptionScreen({
initialPayChannel: resolvedInitialPayChannel, initialPayChannel: resolvedInitialPayChannel,
}); });
const canSubscribeVip = subscriptionType === "vip"; const canSubscribeVip = subscriptionType === "vip";
const analyticsContext =
providedAnalyticsContext ??
getDefaultPaymentAnalyticsContext(subscriptionType);
const vipOfferPlans = useMemo( const vipOfferPlans = useMemo(
() => toVipOfferPlanViews(payment.plans), () => toVipOfferPlanViews(payment.plans),
@@ -73,6 +84,17 @@ export function SubscriptionScreen({
() => toCoinsOfferPlanViews(payment.plans), () => toCoinsOfferPlanViews(payment.plans),
[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( const firstRechargeOffer = useMemo(
() => () =>
getFirstRechargeOfferView({ getFirstRechargeOfferView({
@@ -95,6 +117,12 @@ export function SubscriptionScreen({
const canActivate = const canActivate =
selectedPlan !== null && payment.agreed && !isPaymentBusy; 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(() => { useEffect(() => {
if (isPaymentBusy) return; if (isPaymentBusy) return;
@@ -160,6 +188,7 @@ export function SubscriptionScreen({
className={styles.backSlot} className={styles.backSlot}
onClick={handleBackClick} onClick={handleBackClick}
variant="soft" variant="soft"
analyticsKey="subscription.back"
/> />
</header> </header>
@@ -187,23 +216,13 @@ export function SubscriptionScreen({
<SubscriptionVipOfferSection <SubscriptionVipOfferSection
plans={vipOfferPlans} plans={vipOfferPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null} selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) => onSelectPlan={handleSelectPlan}
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
/> />
) : null} ) : null}
<SubscriptionCoinsOfferSection <SubscriptionCoinsOfferSection
plans={directCoinsPlans} plans={directCoinsPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null} selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) => onSelectPlan={handleSelectPlan}
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
/> />
</div> </div>
+4
View File
@@ -54,6 +54,8 @@ export function TipCheckoutButton({
<> <>
<button <button
type="button" type="button"
data-analytics-key="tip.checkout"
data-analytics-label="Buy coffee tip"
className={styles.checkoutButton} className={styles.checkoutButton}
disabled={disabled || isLoading} disabled={disabled || isLoading}
onClick={onOrder} onClick={onOrder}
@@ -127,6 +129,8 @@ function EzpayRedirectConfirmDialog({
</button> </button>
<button <button
type="button" type="button"
data-analytics-key="tip.external_checkout"
data-analytics-label="Continue to external checkout"
className={`${dialogStyles.button} ${dialogStyles.primary}`} className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming} disabled={isConfirming}
onClick={onConfirm} onClick={onConfirm}
+16
View File
@@ -7,7 +7,12 @@ import { ArrowLeft, Heart, Sparkles } from "lucide-react";
import { CharacterAvatar } from "@/app/_components"; import { CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle"; 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 type { PayChannel } from "@/data/dto/payment";
import {
behaviorAnalytics,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
import { import {
buildTipCoffeePath, buildTipCoffeePath,
DEFAULT_TIP_COFFEE_TYPE, DEFAULT_TIP_COFFEE_TYPE,
@@ -29,6 +34,11 @@ import {
} from "./tip-screen.helpers"; } from "./tip-screen.helpers";
import styles from "./tip-screen.module.css"; import styles from "./tip-screen.module.css";
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
entryPoint: "tip_page",
triggerReason: "ad_landing",
};
export interface TipScreenProps { export interface TipScreenProps {
coffeeType?: TipCoffeeType; coffeeType?: TipCoffeeType;
shouldResumePendingOrder?: boolean; shouldResumePendingOrder?: boolean;
@@ -55,6 +65,7 @@ export function TipScreen({
[coffeeType, payment.plans], [coffeeType, payment.plans],
); );
const priceLabel = formatTipPrice(coffeePlan, coffeeType); const priceLabel = formatTipPrice(coffeePlan, coffeeType);
usePaymentPlanAnalytics(coffeePlan ? [coffeePlan] : [], TIP_ANALYTICS_CONTEXT);
const isPaymentBusy = const isPaymentBusy =
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid; payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
const canCreateOrder = const canCreateOrder =
@@ -136,6 +147,9 @@ export function TipScreen({
const handleOrder = () => { const handleOrder = () => {
if (isAuthLoading) return; if (isAuthLoading) return;
if (coffeePlan) {
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
}
if (!isRealLoginStatus(authState.loginStatus)) { if (!isRealLoginStatus(authState.loginStatus)) {
navigator.openAuth(returnPath); navigator.openAuth(returnPath);
return; return;
@@ -163,6 +177,8 @@ export function TipScreen({
<header className={styles.header}> <header className={styles.header}>
<button <button
type="button" type="button"
data-analytics-key="tip.back"
data-analytics-label="Go back from tip"
className={styles.backButton} className={styles.backButton}
aria-label="Go back" aria-label="Go back"
onClick={handleBack} onClick={handleBack}
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PaymentPlan } from "@/data/dto/payment";
import {
behaviorAnalytics,
sanitizeAnalyticsCheckoutUrl,
type CozsweetBehaviorAnalyticsSdk,
} from "../behavior_analytics";
import {
getBehaviorAnalyticsBaseUrl,
getBehaviorAnalyticsEndpoint,
getBehaviorAnalyticsScriptUrl,
} from "../behavior_analytics_config";
function createSdkMock(): CozsweetBehaviorAnalyticsSdk {
return {
elementClick: vi.fn(),
paywallShown: vi.fn(),
rechargeModalOpen: vi.fn(),
planImpression: vi.fn(),
planClick: vi.fn(),
createOrderStart: vi.fn(),
createOrderSuccess: vi.fn(),
createOrderFailed: vi.fn(),
checkoutOpened: vi.fn(),
checkoutFailed: vi.fn(),
statusPollStart: vi.fn(),
statusPollUpdate: vi.fn(),
statusPollTimeout: vi.fn(),
};
}
const plan = PaymentPlan.from({
planId: "credits-100",
planName: "100 Credits",
orderType: "dol",
vipDays: null,
dolAmount: 100,
creditBalance: 100,
amountCents: 499,
originalAmountCents: null,
currency: "USD",
});
afterEach(() => {
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",
);
});
});
@@ -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" });
});
});
+268
View File
@@ -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<string, unknown>,
): void;
paywallShown(
triggerReason: PaymentAnalyticsTriggerReason,
metadata?: Record<string, unknown>,
): void;
rechargeModalOpen(
entryPoint: PaymentAnalyticsEntryPoint,
triggerReason: PaymentAnalyticsTriggerReason,
metadata?: Record<string, unknown>,
): void;
planImpression(
plan: BehaviorAnalyticsPlan,
position: number,
metadata?: Record<string, unknown>,
): void;
planClick(
plan: BehaviorAnalyticsPlan,
metadata?: Record<string, unknown>,
): void;
createOrderStart(
plan: BehaviorAnalyticsPlan,
payChannel: PayChannel,
metadata?: Record<string, unknown>,
): void;
createOrderSuccess(
plan: BehaviorAnalyticsPlan,
orderId: string,
payChannel: PayChannel,
metadata?: Record<string, unknown>,
): void;
createOrderFailed(
plan: BehaviorAnalyticsPlan,
reason: string,
payChannel: PayChannel,
metadata?: Record<string, unknown>,
): void;
checkoutOpened(
orderId: string,
plan: BehaviorAnalyticsPlan,
payChannel: PayChannel,
checkoutUrl: string,
metadata?: Record<string, unknown>,
): void;
checkoutFailed(
orderId: string,
plan: BehaviorAnalyticsPlan,
reason: string,
payChannel: PayChannel,
metadata?: Record<string, unknown>,
): void;
statusPollStart(
orderId: string,
plan?: BehaviorAnalyticsPlan,
metadata?: Record<string, unknown>,
): void;
statusPollUpdate(
orderId: string,
status: string,
plan?: BehaviorAnalyticsPlan,
metadata?: Record<string, unknown>,
): void;
statusPollTimeout(
orderId: string,
plan?: BehaviorAnalyticsPlan,
metadata?: Record<string, unknown>,
): 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<string, unknown>,
): 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,
),
);
},
};
@@ -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`;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./behavior_analytics";
export * from "./behavior_analytics_config";
export * from "./payment_analytics_context";
@@ -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<PaymentAnalyticsEntryPoint>([
"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<PaymentAnalyticsTriggerReason>([
"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,
};
}
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
getPaymentUrl, getPaymentUrl,
getStripeClientSecret, getStripeClientSecret,
isEzpayPayment, isEzpayPayment,
launchEzpayRedirect,
} from "../payment_launch"; } from "../payment_launch";
describe("payment launch helpers", () => { describe("payment launch helpers", () => {
@@ -40,4 +41,22 @@ describe("payment launch helpers", () => {
}), }),
).toBeNull(); ).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.",
);
});
}); });
+8 -1
View File
@@ -64,6 +64,7 @@ export interface LaunchEzpayRedirectInput {
subscriptionType: PendingPaymentSubscriptionType; subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType; tipCoffeeType?: PendingPaymentTipCoffeeType;
returnTo?: PendingPaymentReturnTo; returnTo?: PendingPaymentReturnTo;
onOpened?: () => void;
onFailed: (errorMessage: string) => void; onFailed: (errorMessage: string) => void;
} }
@@ -73,6 +74,7 @@ export async function launchEzpayRedirect({
subscriptionType, subscriptionType,
tipCoffeeType, tipCoffeeType,
returnTo, returnTo,
onOpened,
onFailed, onFailed,
}: LaunchEzpayRedirectInput): Promise<void> { }: LaunchEzpayRedirectInput): Promise<void> {
log.debug("[payment-launch] launchEzpayRedirect START", { log.debug("[payment-launch] launchEzpayRedirect START", {
@@ -121,5 +123,10 @@ export async function launchEzpayRedirect({
subscriptionType, subscriptionType,
paymentUrl, paymentUrl,
}); });
window.location.href = paymentUrl; try {
window.location.href = paymentUrl;
onOpened?.();
} catch {
onFailed("Could not open Ezpay. Please try again.");
}
} }
@@ -0,0 +1,15 @@
import Script from "next/script";
import { getBehaviorAnalyticsScriptUrl } from "@/lib/analytics/behavior_analytics_config";
export function BehaviorAnalyticsScripts() {
const scriptUrl = getBehaviorAnalyticsScriptUrl();
return (
<Script
id="cozsweet-behavior-analytics"
src={scriptUrl}
strategy="afterInteractive"
/>
);
}
@@ -33,6 +33,27 @@ describe("navigation resolver", () => {
).toBe("/subscription?type=topup"); ).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", () => { it("allows not logged in users to enter chat for guest bootstrap", () => {
expect( expect(
resolveRouteGuardRedirect({ resolveRouteGuardRedirect({
+3
View File
@@ -1,4 +1,5 @@
import type { PayChannel } from "@/data/dto/payment"; import type { PayChannel } from "@/data/dto/payment";
import type { PaymentAnalyticsContext } from "@/lib/analytics/payment_analytics_context";
import type { import type {
PendingChatUnlockKind, PendingChatUnlockKind,
PendingChatPromotion, PendingChatPromotion,
@@ -13,6 +14,7 @@ export interface OpenSubscriptionInput {
payChannel?: PayChannel; payChannel?: PayChannel;
returnTo?: AppSubscriptionReturnTo; returnTo?: AppSubscriptionReturnTo;
replace?: boolean; replace?: boolean;
analytics?: PaymentAnalyticsContext;
} }
export interface StartMessageUnlockInput { export interface StartMessageUnlockInput {
@@ -37,4 +39,5 @@ export interface OpenSubscriptionForPendingUnlockInput {
returnUrl: string; returnUrl: string;
type: AppSubscriptionType; type: AppSubscriptionType;
payChannel?: PayChannel; payChannel?: PayChannel;
analytics?: PaymentAnalyticsContext;
} }
+20 -1
View File
@@ -8,6 +8,11 @@
*/ */
import type { PayChannel } from "@/data/dto/payment"; 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 = { export const ROUTES = {
@@ -29,11 +34,25 @@ export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
export const ROUTE_BUILDERS = { export const ROUTE_BUILDERS = {
subscription: ( subscription: (
type: "vip" | "topup", type: "vip" | "topup",
options: { payChannel?: PayChannel; returnTo?: "chat" } = {}, options: {
payChannel?: PayChannel;
returnTo?: "chat";
analytics?: PaymentAnalyticsContext;
} = {},
): `/subscription?${string}` => { ): `/subscription?${string}` => {
const params = new URLSearchParams({ type }); const params = new URLSearchParams({ type });
if (options.payChannel) params.set("payChannel", options.payChannel); if (options.payChannel) params.set("payChannel", options.payChannel);
if (options.returnTo) params.set("returnTo", options.returnTo); 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; return `${ROUTES.subscription}?${params.toString()}` as const;
}, },
authWithRedirect: (redirectTo: string): `/auth?redirect=${string}` => authWithRedirect: (redirectTo: string): `/auth?redirect=${string}` =>
+10
View File
@@ -10,6 +10,10 @@ import {
peekSubscriptionExplicitExitUrl, peekSubscriptionExplicitExitUrl,
} from "@/lib/navigation/subscription_exit"; } from "@/lib/navigation/subscription_exit";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import {
behaviorAnalytics,
getDefaultPaymentAnalyticsContext,
} from "@/lib/analytics";
import { useAuthSelector } from "@/stores/auth/auth-context"; import { useAuthSelector } from "@/stores/auth/auth-context";
import { useUserSelector } from "@/stores/user/user-context"; import { useUserSelector } from "@/stores/user/user-context";
@@ -88,12 +92,16 @@ export function useAppNavigator(): AppNavigator {
payChannel = getDefaultPayChannel(), payChannel = getDefaultPayChannel(),
returnTo = null, returnTo = null,
replace: shouldReplace = false, replace: shouldReplace = false,
analytics = getDefaultPaymentAnalyticsContext(type),
}: OpenSubscriptionInput): void => { }: OpenSubscriptionInput): void => {
const target = ROUTE_BUILDERS.subscription(type, { const target = ROUTE_BUILDERS.subscription(type, {
payChannel, payChannel,
returnTo: returnTo ?? undefined, returnTo: returnTo ?? undefined,
analytics,
}); });
behaviorAnalytics.rechargeModalOpen(analytics);
const nextUrl = resolveAuthenticatedNavigation({ const nextUrl = resolveAuthenticatedNavigation({
loginStatus, loginStatus,
targetUrl: target, targetUrl: target,
@@ -153,6 +161,7 @@ export function useAppNavigator(): AppNavigator {
returnUrl, returnUrl,
type, type,
payChannel = getDefaultPayChannel(), payChannel = getDefaultPayChannel(),
analytics,
}: OpenSubscriptionForPendingUnlockInput): void => { }: OpenSubscriptionForPendingUnlockInput): void => {
void (async () => { void (async () => {
await NavigationStorage.savePendingChatUnlock({ await NavigationStorage.savePendingChatUnlock({
@@ -169,6 +178,7 @@ export function useAppNavigator(): AppNavigator {
type, type,
payChannel, payChannel,
returnTo: "chat", returnTo: "chat",
analytics,
}); });
})(); })();
}, },
@@ -7,6 +7,7 @@ import {
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
PaymentPlansResponse, PaymentPlansResponse,
} from "@/data/dto/payment"; } from "@/data/dto/payment";
import { behaviorAnalytics } from "@/lib/analytics";
import { paymentMachine } from "@/stores/payment/payment-machine"; import { paymentMachine } from "@/stores/payment/payment-machine";
import { import {
MAX_ORDER_POLLING_MS, MAX_ORDER_POLLING_MS,
@@ -94,6 +95,7 @@ const tipPlan = {
function createTestPaymentMachine( function createTestPaymentMachine(
overrides: Partial<{ overrides: Partial<{
createOrderSpy: CreateOrderSpy; createOrderSpy: CreateOrderSpy;
createOrderError: Error;
orderStatus: "pending" | "paid" | "failed"; orderStatus: "pending" | "paid" | "failed";
orderStatuses: Array<"pending" | "paid" | "failed">; orderStatuses: Array<"pending" | "paid" | "failed">;
}> = {}, }> = {},
@@ -124,6 +126,7 @@ function createTestPaymentMachine(
CreateOrderInput CreateOrderInput
>(async ({ input }) => { >(async ({ input }) => {
createOrderSpy(input); createOrderSpy(input);
if (overrides.createOrderError) throw overrides.createOrderError;
return CreatePaymentOrderResponse.from({ return CreatePaymentOrderResponse.from({
orderId: "pay_test_001", orderId: "pay_test_001",
payParams: { payParams: {
@@ -152,6 +155,7 @@ function createTestPaymentMachine(
describe("paymentMachine", () => { describe("paymentMachine", () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.restoreAllMocks();
}); });
it("loads plans and selects the default monthly VIP plan", async () => { 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 () => { 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<CreateOrderSpy>(); const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor( const actor = createActor(
createTestPaymentMachine({ createOrderSpy }), createTestPaymentMachine({ createOrderSpy }),
@@ -481,6 +497,50 @@ describe("paymentMachine", () => {
expect(context.orderStatus).toBe("paid"); expect(context.orderStatus).toBe("paid");
expect(context.orderPollingStartedAt).toBeNull(); expect(context.orderPollingStartedAt).toBeNull();
expect(context.launchNonce).toBe(1); 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(); actor.stop();
}); });
@@ -533,6 +593,9 @@ describe("paymentMachine", () => {
}); });
it("moves to failed state when a pending order exceeds polling timeout", async () => { 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"); const now = new Date("2026-06-30T00:00:00.000Z");
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(now); vi.setSystemTime(now);
@@ -555,6 +618,10 @@ describe("paymentMachine", () => {
expect(context.orderStatus).toBe("failed"); expect(context.orderStatus).toBe("failed");
expect(context.orderPollingStartedAt).toBeNull(); expect(context.orderPollingStartedAt).toBeNull();
expect(context.errorMessage).toBe(PAYMENT_TIMEOUT_ERROR_MESSAGE); expect(context.errorMessage).toBe(PAYMENT_TIMEOUT_ERROR_MESSAGE);
expect(statusPollTimeout).toHaveBeenCalledWith(
"pay_test_001",
expect.objectContaining({ planId: "vip_monthly" }),
);
actor.stop(); actor.stop();
}); });
@@ -179,6 +179,7 @@ export function selectPlanState(
export function resetOrderState(): Partial<PaymentState> { export function resetOrderState(): Partial<PaymentState> {
return { return {
currentOrderId: null, currentOrderId: null,
currentOrderPlanId: null,
payParams: null, payParams: null,
orderStatus: null, orderStatus: null,
orderPollingStartedAt: null, orderPollingStartedAt: null,
+120 -37
View File
@@ -3,6 +3,7 @@
*/ */
import { assign, setup } from "xstate"; import { assign, setup } from "xstate";
import { behaviorAnalytics } from "@/lib/analytics";
import type { PaymentEvent } from "./payment-events"; import type { PaymentEvent } from "./payment-events";
import { initialState, type PaymentState } from "./payment-state"; import { initialState, type PaymentState } from "./payment-state";
import { import {
@@ -28,6 +29,19 @@ export { initialState } from "./payment-state";
const POLL_DELAY_MS = 4000; 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({ export const paymentMachine = setup({
types: { types: {
context: {} as PaymentState, context: {} as PaymentState,
@@ -192,6 +206,10 @@ export const paymentMachine = setup({
}, },
creatingOrder: { creatingOrder: {
entry: ({ context }) => {
const plan = getSelectedPlan(context);
if (plan) behaviorAnalytics.createOrderStart(plan, context.payChannel);
},
invoke: { invoke: {
src: "createOrder", src: "createOrder",
input: ({ context }) => ({ input: ({ context }) => ({
@@ -201,20 +219,45 @@ export const paymentMachine = setup({
}), }),
onDone: { onDone: {
target: "pollingOrder", target: "pollingOrder",
actions: assign(({ context, event }) => ({ actions: [
currentOrderId: event.output.orderId, ({ context, event }) => {
payParams: event.output.payParams, const plan = getSelectedPlan(context);
orderStatus: "pending", if (plan) {
orderPollingStartedAt: Date.now(), behaviorAnalytics.createOrderSuccess(
errorMessage: null, plan,
launchNonce: context.launchNonce + 1, 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: { onError: {
target: "failed", target: "failed",
actions: assign(({ event }) => ({ actions: [
errorMessage: toPaymentErrorMessage(event.error), ({ 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", guard: ({ event }) => event.output.status === "paid",
target: "paid", target: "paid",
actions: assign(({ event }) => ({ actions: [
orderStatus: event.output.status, ({ context, event }) =>
orderPollingStartedAt: null, behaviorAnalytics.statusPollUpdate(
errorMessage: null, context.currentOrderId ?? "",
})), event.output.status,
getCurrentOrderPlan(context),
),
assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: null,
})),
],
}, },
{ {
guard: ({ event }) => event.output.status === "failed", guard: ({ event }) => event.output.status === "failed",
target: "failed", target: "failed",
actions: assign(({ event }) => ({ actions: [
orderStatus: event.output.status, ({ context, event }) =>
orderPollingStartedAt: null, behaviorAnalytics.statusPollUpdate(
errorMessage: "Payment failed or was cancelled.", context.currentOrderId ?? "",
})), event.output.status,
getCurrentOrderPlan(context),
),
assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: "Payment failed or was cancelled.",
})),
],
}, },
{ {
guard: ({ context }) => guard: ({ context }) =>
hasOrderPollingTimedOut(context.orderPollingStartedAt), hasOrderPollingTimedOut(context.orderPollingStartedAt),
target: "failed", target: "failed",
actions: assign({ actions: [
orderStatus: "failed", ({ context, event }) => {
orderPollingStartedAt: null, const orderId = context.currentOrderId ?? "";
errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE, 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", target: "waitingForPayment",
actions: assign(({ event }) => ({ actions: [
orderStatus: event.output.status, ({ context, event }) =>
errorMessage: null, behaviorAnalytics.statusPollUpdate(
})), context.currentOrderId ?? "",
event.output.status,
getCurrentOrderPlan(context),
),
assign(({ event }) => ({
orderStatus: event.output.status,
errorMessage: null,
})),
],
}, },
], ],
onError: { onError: {
@@ -354,13 +433,17 @@ export const paymentMachine = setup({
}, },
PaymentReturned: { PaymentReturned: {
target: ".pollingOrder", target: ".pollingOrder",
actions: assign(({ event }) => ({ actions: [
currentOrderId: event.orderId, ({ event }) => behaviorAnalytics.statusPollStart(event.orderId),
payParams: null, assign(({ event }) => ({
orderStatus: "pending", currentOrderId: event.orderId,
orderPollingStartedAt: event.createdAt ?? Date.now(), currentOrderPlanId: null,
errorMessage: null, payParams: null,
})), orderStatus: "pending",
orderPollingStartedAt: event.createdAt ?? Date.now(),
errorMessage: null,
})),
],
}, },
PaymentLaunchFailed: { PaymentLaunchFailed: {
target: ".failed", target: ".failed",
+2
View File
@@ -18,6 +18,7 @@ export interface PaymentState {
autoRenew: boolean; autoRenew: boolean;
agreed: boolean; agreed: boolean;
currentOrderId: string | null; currentOrderId: string | null;
currentOrderPlanId: string | null;
payParams: Record<string, unknown> | null; payParams: Record<string, unknown> | null;
orderStatus: PaymentOrderStatus | null; orderStatus: PaymentOrderStatus | null;
orderPollingStartedAt: number | null; orderPollingStartedAt: number | null;
@@ -34,6 +35,7 @@ export const initialState: PaymentState = {
autoRenew: true, autoRenew: true,
agreed: true, agreed: true,
currentOrderId: null, currentOrderId: null,
currentOrderPlanId: null,
payParams: null, payParams: null,
orderStatus: null, orderStatus: null,
orderPollingStartedAt: null, orderPollingStartedAt: null,