feat(analytics): add behavior and payment funnel tracking

This commit is contained in:
2026-07-14 16:54:13 +08:00
parent ca55723e48
commit 81d6489978
70 changed files with 1576 additions and 81 deletions
@@ -14,7 +14,12 @@ describe("shared Tailwind components", () => {
<BackButton onClick={() => undefined} variant="ghost" />,
);
const darkHtml = renderToStaticMarkup(
<BackButton href="/splash" variant="dark" ariaLabel="Back to home" />,
<BackButton
href="/splash"
variant="dark"
ariaLabel="Back to home"
analyticsKey="chat.back_to_home"
/>,
);
expect(linkHtml).toContain('href="/chat"');
@@ -27,6 +32,7 @@ describe("shared Tailwind components", () => {
expect(actionHtml).toContain("hover:opacity-70");
expect(darkHtml).toContain('href="/splash"');
expect(darkHtml).toContain('aria-label="Back to home"');
expect(darkHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(darkHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(darkHtml).toContain("text-white");
});
+11 -1
View File
@@ -6,6 +6,7 @@ import Link, { type LinkProps } from "next/link";
interface BackButtonBaseProps {
className?: string;
ariaLabel?: string;
analyticsKey?: string;
iconSize?: number;
variant?: "floating" | "soft" | "ghost" | "dark";
}
@@ -41,6 +42,7 @@ export function BackButton({
onClick,
className,
ariaLabel = "Back",
analyticsKey,
iconSize = 24,
variant = "floating",
}: BackButtonProps) {
@@ -57,7 +59,13 @@ export function BackButton({
if (href) {
return (
<Link href={href} className={buttonClassName} aria-label={ariaLabel}>
<Link
href={href}
className={buttonClassName}
aria-label={ariaLabel}
data-analytics-key={analyticsKey}
data-analytics-label={ariaLabel}
>
{icon}
</Link>
);
@@ -69,6 +77,8 @@ export function BackButton({
className={buttonClassName}
onClick={onClick}
aria-label={ariaLabel}
data-analytics-key={analyticsKey}
data-analytics-label={ariaLabel}
>
{icon}
</button>
@@ -101,5 +101,9 @@ describe("core Tailwind components", () => {
expect(privateRoomHtml).toMatch(
/<button[^>]*aria-current="page"[^>]*>.*?<span>Elio Private room<\/span>/,
);
expect(chatHtml).toContain('data-analytics-key="navigation.chat"');
expect(privateRoomHtml).toContain(
'data-analytics-key="navigation.private_room"',
);
});
});
@@ -24,6 +24,8 @@ export function AppBottomNav({
<nav className={getRootClass(variant)} aria-label="Primary navigation">
<button
type="button"
data-analytics-key="navigation.chat"
data-analytics-label="Chat navigation"
className={getButtonClass(activeItem === "chat")}
aria-current={activeItem === "chat" ? "page" : undefined}
onClick={onChatClick}
@@ -33,6 +35,8 @@ export function AppBottomNav({
</button>
<button
type="button"
data-analytics-key="navigation.private_room"
data-analytics-label="Private room navigation"
className={getButtonClass(activeItem === "privateRoom")}
aria-current={activeItem === "privateRoom" ? "page" : undefined}
onClick={onPrivateRoomClick}
@@ -0,0 +1,82 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PaymentPlan } from "@/data/dto/payment";
import { behaviorAnalytics } from "@/lib/analytics";
import { usePaymentPlanAnalytics } from "../use-payment-plan-analytics";
const firstPlan = PaymentPlan.from({
planId: "plan-1",
planName: "Plan One",
orderType: "dol",
vipDays: null,
dolAmount: 100,
creditBalance: 100,
amountCents: 499,
originalAmountCents: null,
currency: "USD",
});
const secondPlan = PaymentPlan.from({
planId: "plan-2",
planName: "Plan Two",
orderType: "dol",
vipDays: null,
dolAmount: 250,
creditBalance: 250,
amountCents: 999,
originalAmountCents: null,
currency: "USD",
});
function Harness({ plans }: { plans: readonly PaymentPlan[] }) {
usePaymentPlanAnalytics(plans, {
entryPoint: "subscription_direct",
triggerReason: "manual_recharge",
});
return null;
}
describe("usePaymentPlanAnalytics", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
});
it("tracks displayed order once per plan during a page mount", () => {
const impression = vi
.spyOn(behaviorAnalytics, "planImpression")
.mockImplementation(() => undefined);
act(() => root.render(<Harness plans={[firstPlan]} />));
act(() => root.render(<Harness plans={[firstPlan, secondPlan]} />));
act(() => root.render(<Harness plans={[firstPlan, secondPlan]} />));
expect(impression).toHaveBeenCalledTimes(2);
expect(impression).toHaveBeenNthCalledWith(
1,
firstPlan,
1,
expect.any(Object),
);
expect(impression).toHaveBeenNthCalledWith(
2,
secondPlan,
2,
expect.any(Object),
);
});
});
+59 -5
View File
@@ -8,6 +8,7 @@ import {
isEzpayPayment,
launchEzpayRedirect,
} from "@/lib/payment/payment_launch";
import { behaviorAnalytics } from "@/lib/analytics";
import type {
PendingPaymentReturnTo,
PendingPaymentSubscriptionType,
@@ -52,6 +53,38 @@ export interface PaymentLaunchFlow {
stripeClientSecret: string | null;
}
function trackPaymentCheckoutOpened(
payment: PaymentContextState,
checkoutUrl: string,
): void {
const plan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
if (!payment.currentOrderId || !plan) return;
behaviorAnalytics.checkoutOpened({
orderId: payment.currentOrderId,
plan,
payChannel: payment.payChannel,
checkoutUrl,
});
}
function trackPaymentCheckoutFailed(
payment: PaymentContextState,
reason: "missing_checkout_url" | "payment_redirect_failed",
): void {
const plan = payment.plans.find(
(item) => item.planId === payment.selectedPlanId,
);
if (!payment.currentOrderId || !plan) return;
behaviorAnalytics.checkoutFailed({
orderId: payment.currentOrderId,
plan,
payChannel: payment.payChannel,
reason,
});
}
export function shouldShowEzpayConfirmation({
currentOrderId,
isProduction,
@@ -87,7 +120,6 @@ export function usePaymentLaunchFlow({
const ezpayPaymentUrl = payment.payParams
? getPaymentUrl(payment.payParams)
: null;
useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
return;
@@ -95,7 +127,10 @@ export function usePaymentLaunchFlow({
launchedNonceRef.current = payment.launchNonce;
const clientSecret = getStripeClientSecret(payment.payParams);
if (clientSecret) return;
if (clientSecret) {
trackPaymentCheckoutOpened(payment, "stripe_embedded");
return;
}
const paymentUrl = getPaymentUrl(payment.payParams);
if (paymentUrl) {
@@ -117,16 +152,29 @@ export function usePaymentLaunchFlow({
subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}),
onFailed: (errorMessage) =>
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }),
onOpened: () => trackPaymentCheckoutOpened(payment, paymentUrl),
onFailed: (errorMessage) => {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
});
return;
}
window.location.href = paymentUrl;
try {
window.location.href = paymentUrl;
trackPaymentCheckoutOpened(payment, paymentUrl);
} catch {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: "Could not open the payment page. Please try again.",
});
}
return;
}
trackPaymentCheckoutFailed(payment, "missing_checkout_url");
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage: UNSUPPORTED_PAYMENT_PARAMS_MESSAGE,
@@ -135,8 +183,12 @@ export function usePaymentLaunchFlow({
log,
logScope,
payment.currentOrderId,
payment,
payment.launchNonce,
payment.payChannel,
payment.payParams,
payment.plans,
payment.selectedPlanId,
paymentDispatch,
returnTo,
subscriptionType,
@@ -183,7 +235,9 @@ export function usePaymentLaunchFlow({
subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}),
onOpened: () => trackPaymentCheckoutOpened(payment, ezpayPaymentUrl),
onFailed: (errorMessage) => {
trackPaymentCheckoutFailed(payment, "payment_redirect_failed");
setIsConfirmingEzpay(false);
paymentDispatch({ type: "PaymentLaunchFailed", errorMessage });
},
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useRef } from "react";
import type { PaymentPlan } from "@/data/dto/payment";
import {
behaviorAnalytics,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
export function usePaymentPlanAnalytics(
plans: readonly PaymentPlan[],
context: PaymentAnalyticsContext,
): void {
const trackedRef = useRef(new Set<string>());
const { entryPoint, triggerReason } = context;
useEffect(() => {
plans.forEach((plan, index) => {
const key = `${entryPoint}:${triggerReason}:${plan.planId}`;
if (trackedRef.current.has(key)) return;
trackedRef.current.add(key);
behaviorAnalytics.planImpression(plan, index + 1, {
entryPoint,
triggerReason,
});
});
}, [entryPoint, plans, triggerReason]);
}
@@ -52,6 +52,7 @@ describe("auth Tailwind components", () => {
expect(html).toContain("w-full");
expect(html).toContain("gap-md");
expect(html).toContain("Login");
expect(html).toContain('data-analytics-key="auth.email_login"');
});
it("renders EmailRegisterForm with Tailwind form layout", () => {
@@ -62,6 +63,7 @@ describe("auth Tailwind components", () => {
expect(html).toContain("w-full");
expect(html).toContain("gap-md");
expect(html).toContain("Register");
expect(html).toContain('data-analytics-key="auth.email_register"');
});
it("renders AuthBackground with Tailwind layout classes", () => {
@@ -128,6 +130,7 @@ describe("auth Tailwind components", () => {
);
expect(html).toContain("Login with Facebook");
expect(html).toContain('data-analytics-key="auth.facebook_login"');
expect(html).toContain("bg-auth-surface");
expect(html).toContain("enabled:hover:brightness-[0.97]");
expect(html).toContain("Other Sign In Options");
@@ -83,6 +83,9 @@ export function AuthEmailPanel({
<button
type="button"
data-analytics-key={
mode === "login" ? "auth.open_register" : "auth.open_email_login"
}
className="self-center border-0 bg-transparent pt-md text-(length:--responsive-body) text-auth-text-primary underline underline-offset-[3px] transition-opacity duration-150 hover:opacity-80"
onClick={() => setMode((m) => (m === "login" ? "register" : "login"))}
>
@@ -93,6 +96,8 @@ export function AuthEmailPanel({
<button
type="button"
data-analytics-key="auth.open_other_options"
data-analytics-label="Open other sign-in options"
className="self-center border-0 bg-transparent py-md text-(length:--responsive-body) text-auth-text-primary underline underline-offset-[3px] transition-opacity duration-150 hover:opacity-80"
onClick={() => setShowOptions(true)}
>
@@ -63,6 +63,8 @@ export function AuthFacebookPanel({
<button
type="button"
data-analytics-key="auth.facebook_login"
data-analytics-label="Facebook login"
className="mt-md inline-flex min-h-(--auth-field-height) w-full cursor-pointer items-center justify-center gap-md rounded-full border-0 bg-auth-surface px-lg text-(length:--responsive-body) font-semibold text-auth-text-primary shadow-[0_2px_6px_rgba(0,0,0,0.06)] transition-[filter,transform,opacity] duration-150 enabled:hover:brightness-[0.97] enabled:active:scale-98 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => handleFacebook()}
disabled={isLoading}
@@ -87,6 +89,8 @@ export function AuthFacebookPanel({
<button
type="button"
data-analytics-key="auth.open_other_options"
data-analytics-label="Open other sign-in options"
className="self-center border-0 bg-transparent py-md text-(length:--responsive-body) text-auth-text-primary underline underline-offset-[3px] transition-opacity duration-150 hover:opacity-80"
onClick={() => setShowOptions(true)}
>
@@ -52,6 +52,8 @@ export function AuthOtherOptionsDialog({
{mode === "email" ? (
<AuthSocialButton
data-analytics-key="auth.facebook_login"
data-analytics-label="Facebook login"
icon={<AuthProviderIcon provider="facebook" />}
onClick={() => {
onClose();
@@ -64,6 +66,8 @@ export function AuthOtherOptionsDialog({
{showNonProductionEmailOption ? (
<AuthSocialButton
data-analytics-key="auth.open_email_login"
data-analytics-label="Email login option"
icon={<MdEmail size={20} color="var(--color-auth-text-primary)" />}
onClick={() => {
onClose();
@@ -78,6 +82,8 @@ export function AuthOtherOptionsDialog({
<div className="w-full">{googleSlot}</div>
) : (
<AuthSocialButton
data-analytics-key="auth.google_login"
data-analytics-label="Google login"
icon={<AuthProviderIcon provider="google" />}
onClick={() => {
onClose();
+1 -1
View File
@@ -29,7 +29,7 @@ export function AuthPanel() {
return (
<div className="relative flex min-h-0 w-full flex-auto flex-col">
<BackButton onClick={handleBack} />
<BackButton onClick={handleBack} analyticsKey="auth.back" />
{state.authPanelMode === "facebook" ? (
<AuthFacebookPanel onSwitchToEmail={switchToEmail} />
+6 -1
View File
@@ -98,7 +98,12 @@ export function EmailLoginForm({
/>
{/* 优先级:本地校验错误 > 后端 globalError(两个互斥场景:本地校验未过时,UI 不会再有 globalError */}
<AuthErrorMessage message={validationError ?? globalError ?? null} />
<AuthPrimaryButton type="submit" isLoading={isLoading}>
<AuthPrimaryButton
type="submit"
isLoading={isLoading}
data-analytics-key="auth.email_login"
data-analytics-label="Email login"
>
Login
</AuthPrimaryButton>
</form>
@@ -147,7 +147,12 @@ export function EmailRegisterForm({
/>
{/* 优先级:本地校验错误 > 后端 globalError */}
<AuthErrorMessage message={validationError ?? globalError ?? null} />
<AuthPrimaryButton type="submit" isLoading={isLoading}>
<AuthPrimaryButton
type="submit"
isLoading={isLoading}
data-analytics-key="auth.email_register"
data-analytics-label="Email register"
>
Register
</AuthPrimaryButton>
</form>
@@ -96,6 +96,7 @@ describe("chat Tailwind components", () => {
);
expect(activeHtml).toContain('aria-label="Send message"');
expect(activeHtml).toContain('data-analytics-key="chat.send_message"');
expect(activeHtml).toContain("size-(--chat-send-button-size,40px)");
expect(activeHtml).toContain(
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
@@ -167,6 +168,8 @@ describe("chat Tailwind components", () => {
expect(memberHtml).toContain('href="/splash"');
expect(memberHtml).toContain('aria-label="Back to home"');
expect(memberHtml).toContain('aria-label="Menu"');
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)");
});
@@ -264,6 +267,7 @@ describe("chat Tailwind components", () => {
expect(html).toContain("We found 3 locked messages");
expect(html).toContain("Could not unlock history.");
expect(html).toContain("Unlocking...");
expect(html).toContain('data-analytics-key="chat.unlock_history"');
expect(html).toContain("text-(--color-pwa-button-text,#ffffff)");
expect(html).toContain("disabled");
});
@@ -302,6 +306,7 @@ describe("chat Tailwind components", () => {
expect(html).toContain("text-(--color-pwa-button-text,#ffffff)");
expect(html).toContain("bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)]");
expect(html).toContain("Top up now");
expect(html).toContain('data-analytics-key="chat.topup"');
});
});
@@ -81,6 +81,8 @@ export function BrowserHintOverlay({
return (
<button
type="button"
data-analytics-key="chat.external_browser_hint"
data-analytics-label="External browser hint"
className={[
OVERLAY_BASE_CLASS_NAME,
isExpanded ? OVERLAY_EXPANDED_CLASS_NAME : OVERLAY_COLLAPSED_CLASS_NAME,
+5
View File
@@ -25,6 +25,8 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
{isGuest ? (
<button
type="button"
data-analytics-key="chat.open_signup"
data-analytics-label="Open sign up"
className="flex w-full cursor-pointer items-center justify-center gap-(--spacing-sm,8px) border-0 bg-(--color-accent,#f84d96) px-(--spacing-md,12px) py-(--spacing-sm,8px) text-center text-(length:--responsive-caption,var(--font-size-sm,12px)) font-medium text-white"
onClick={() => navigator.openAuth(ROUTES.chat)}
aria-label="Sign up to unlock more features"
@@ -46,10 +48,13 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
href={ROUTES.splash}
variant="dark"
ariaLabel="Back to home"
analyticsKey="chat.back_to_home"
/>
<button
type="button"
data-analytics-key="chat.open_menu"
data-analytics-label="Open chat menu"
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-(--radius-full,999px) border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={() => navigator.push(ROUTES.sidebar)}
aria-label="Menu"
@@ -69,6 +69,8 @@ export function ChatInsufficientCreditsBanner({
) : null}
<button
type="button"
data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits"
className="min-h-(--responsive-control-height,48px) w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(135deg,#ffb8e0_0%,#f57ec0_100%)] px-(--responsive-card-padding-lg,24px) text-(length:--responsive-card-title,18px) font-bold text-white shadow-[0_2px_8px_rgba(0,0,0,0.15)] transition-[box-shadow,transform] duration-150 hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)] active:scale-98"
onClick={handleClick}
aria-label={ctaLabel}
@@ -29,6 +29,8 @@ export function ChatSendButton({
return (
<button
type="button"
data-analytics-key="chat.send_message"
data-analytics-label="Send message"
className={[
"flex aspect-square size-(--chat-send-button-size,40px) shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-(--color-button-gradient-end,#fc69df) text-white transition-[background,transform] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
isActive
@@ -23,6 +23,8 @@ export function FirstRechargeOfferBanner({
<section className={styles.banner} aria-label="First recharge offer">
<button
type="button"
data-analytics-key="chat.first_recharge_offer"
data-analytics-label="Open first recharge offer"
className={styles.contentButton}
onClick={onClick}
>
@@ -68,6 +68,8 @@ export function FullscreenImageViewer({
</button>
<button
type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock private image"
className={styles.unlockButton}
disabled={isUnlockingImagePaywall || !onUnlockImagePaywall}
onClick={onUnlockImagePaywall}
@@ -53,6 +53,8 @@ export function HistoryUnlockDialog({
</button>
<button
type="button"
data-analytics-key="chat.unlock_history"
data-analytics-label="Unlock chat history"
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-(--radius-bottom-sheet,28px) border-0 bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-(length:--responsive-body,var(--font-size-lg,16px)) font-semibold text-(--color-pwa-button-text,#ffffff) disabled:cursor-not-allowed disabled:opacity-72"
onClick={onConfirm}
disabled={isLoading}
@@ -72,6 +72,8 @@ export function InsufficientCreditsDialog({
</button>
<button
type="button"
data-analytics-key="chat.topup"
data-analytics-label="Top up chat credits"
className="flex min-h-(--pwa-button-height,44px) flex-auto cursor-pointer items-center justify-center rounded-(--radius-bottom-sheet,28px) border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-(--color-pwa-button-text,#ffffff) shadow-[0_6px_14px_rgba(246,87,160,0.28)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={onConfirm}
>
@@ -33,6 +33,8 @@ export function LockedImageMessageCard({
</p>
<button
type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock private image"
className="mt-(--spacing-md,12px) w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(90deg,#ff67e0,#ff52a2)] px-(--spacing-md,12px) py-[clamp(9px,1.852vw,10px)] text-(length:--responsive-body,14px) font-bold text-white disabled:cursor-not-allowed disabled:opacity-65 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
disabled={isUnlocking || !onUnlock}
onClick={onUnlock}
@@ -32,6 +32,8 @@ export function PrivateMessageCard({
</p>
<button
type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock private message"
className="mt-(--spacing-md,12px) w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(90deg,#ff67e0,#ff52a2)] px-(--spacing-md,12px) py-[clamp(9px,1.852vw,10px)] text-(length:--responsive-body,14px) font-bold text-white disabled:cursor-not-allowed disabled:opacity-65 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
disabled={isUnlocking || !onUnlock}
onClick={onUnlock}
+2
View File
@@ -124,6 +124,8 @@ export function VoiceBubble({
</p>
<button
type="button"
data-analytics-key="chat.unlock_message"
data-analytics-label="Unlock voice message"
className={styles.unlockButton}
disabled={isUnlocking || !onUnlock}
onClick={onUnlock}
@@ -1,6 +1,9 @@
"use client";
import { useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth";
import { behaviorAnalytics } from "@/lib/analytics";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
@@ -33,6 +36,27 @@ export function useChatMessageLimitBanner({
const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip);
const view = getInsufficientCreditsMessageLimitView(loginStatus);
const visible = shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
});
const trackedVisibleRef = useRef(false);
useEffect(() => {
if (!visible) {
trackedVisibleRef.current = false;
return;
}
if (trackedVisibleRef.current) return;
trackedVisibleRef.current = true;
behaviorAnalytics.paywallShown(
{
entryPoint: "chat_input",
triggerReason: "insufficient_credits",
},
{ isVip },
);
}, [isVip, visible]);
function unlock(): void {
if (view.action === "auth") {
@@ -43,15 +67,16 @@ export function useChatMessageLimitBanner({
navigator.openSubscription({
type: getInsufficientCreditsSubscriptionType(isVip),
returnTo: "chat",
analytics: {
entryPoint: "chat_input",
triggerReason: "insufficient_credits",
},
});
}
return {
...view,
visible: shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
}),
visible,
unlock,
};
}
@@ -1,9 +1,10 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { shallowEqual } from "@xstate/react";
import type { ChatLockType } from "@/data/schemas/chat";
import { behaviorAnalytics } from "@/lib/analytics";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
@@ -60,6 +61,7 @@ export function useChatUnlockNavigationFlow({
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip);
const trackedPaywallRef = useRef<string | null>(null);
const chatState = useChatSelector(
(state) => ({
historyLoaded: state.context.historyLoaded,
@@ -77,6 +79,22 @@ export function useChatUnlockNavigationFlow({
enabled,
});
useEffect(() => {
if (!unlockPaywallRequest) return;
const requestKey = `${unlockPaywallRequest.displayMessageId}:${unlockPaywallRequest.clientLockId ?? ""}`;
if (trackedPaywallRef.current === requestKey) return;
trackedPaywallRef.current = requestKey;
behaviorAnalytics.paywallShown(
{
entryPoint: "chat_unlock",
triggerReason: unlockPaywallRequest.promotion
? "ad_landing"
: "insufficient_credits",
},
{ isVip },
);
}, [isVip, unlockPaywallRequest]);
useEffect(() => {
if (!enabled) return;
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
@@ -172,6 +190,12 @@ export function useChatUnlockNavigationFlow({
promotion: unlockPaywallRequest.promotion,
returnUrl,
type: getInsufficientCreditsSubscriptionType(isVip),
analytics: {
entryPoint: "chat_unlock",
triggerReason: unlockPaywallRequest.promotion
? "ad_landing"
: "insufficient_credits",
},
});
}
@@ -139,7 +139,14 @@ export function useFirstRechargeOfferBanner({
}
function claim(): void {
navigator.openSubscription({ type: "vip", returnTo: "chat" });
navigator.openSubscription({
type: "vip",
returnTo: "chat",
analytics: {
entryPoint: "chat_offer_banner",
triggerReason: "vip_cta",
},
});
}
return {
+9
View File
@@ -1,6 +1,9 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { SerwistProvider } from "@serwist/turbopack/react";
import { SessionProvider } from "@/providers/session-provider";
import { BehaviorAnalyticsScripts } from "@/providers/behavior-analytics-scripts";
import { getBehaviorAnalyticsEndpoint } from "@/lib/analytics/behavior_analytics_config";
import { geistSans, geistMono, athelas } from "@/core/fonts";
import "./globals.css";
@@ -43,6 +46,12 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${athelas.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Script id="cozsweet-analytics-config" strategy="beforeInteractive">
{`window.COZSWEET_ANALYTICS_ENDPOINT=${JSON.stringify(
getBehaviorAnalyticsEndpoint(),
)}`}
</Script>
<BehaviorAnalyticsScripts />
{/* NextAuth SessionProvider:为所有客户端组件提供 useSession() 上下文 */}
<SessionProvider>
<RootProviders>
@@ -61,6 +61,9 @@ describe("PrivateAlbumCard", () => {
expect(html).toContain("8 photos");
expect(html).toContain("A quiet morning by the water.");
expect(html).toContain('aria-label="Open 8 private album photos"');
expect(html).toContain(
'data-analytics-key="private_album.open_gallery"',
);
});
it("uses the backend first image as the locked cover", () => {
@@ -80,6 +83,7 @@ describe("PrivateAlbumCard", () => {
);
expect(html).toContain("Only for you.");
expect(html).toContain("320 credits");
expect(html).toContain('data-analytics-key="private_album.unlock"');
});
it("renders an empty cover when an unlocked album has no image", () => {
@@ -61,6 +61,8 @@ export function PrivateAlbumCard({
{isLocked ? (
<button
type="button"
data-analytics-key="private_album.unlock"
data-analytics-label="Unlock private album"
className={styles.lockedPreview}
disabled={isUnlocking}
onClick={onUnlock}
@@ -93,6 +95,8 @@ export function PrivateAlbumCard({
) : firstImageUrl ? (
<button
type="button"
data-analytics-key="private_album.open_gallery"
data-analytics-label="Open private album gallery"
className={styles.mediaCover}
onClick={onOpenGallery}
aria-label={`Open ${photoCount} private album photos`}
@@ -38,6 +38,8 @@ export function UnlockConfirmDialog({
</button>
<button
type="button"
data-analytics-key="private_album.unlock_confirm"
data-analytics-label="Confirm private album unlock"
className={styles.dialogPrimary}
disabled={isUnlocking}
onClick={onConfirm}
+9 -1
View File
@@ -109,7 +109,13 @@ export function PrivateRoomScreen() {
navigator.openAuth(ROUTES.privateRoom);
return;
}
navigator.openSubscription({ type: "topup" });
navigator.openSubscription({
type: "topup",
analytics: {
entryPoint: "private_room",
triggerReason: "vip_cta",
},
});
};
const handleOpenGallery = (albumId: string) => {
@@ -171,6 +177,8 @@ export function PrivateRoomScreen() {
<button
type="button"
data-analytics-key="private_room.primary_cta"
data-analytics-label="Open private room top up"
className={styles.primaryCta}
onClick={handleTopUpClick}
>
+12 -1
View File
@@ -4,6 +4,7 @@ import { type Dispatch, useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth";
import { useGuestLoginBootstrap } from "@/hooks/use-guest-login-bootstrap";
import { behaviorAnalytics } from "@/lib/analytics";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { AuthEvent } from "@/stores/auth/auth-events";
@@ -84,7 +85,17 @@ export function usePrivateRoomUnlockPaywallNavigation({
if (isPrivateRoomAuthRequired(loginStatus)) {
navigator.openAuth(ROUTES.privateRoom);
} else {
navigator.openSubscription({ type: "topup" });
behaviorAnalytics.paywallShown({
entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits",
});
navigator.openSubscription({
type: "topup",
analytics: {
entryPoint: "private_album_unlock",
triggerReason: "insufficient_credits",
},
});
}
roomDispatch({ type: "PrivateRoomUnlockPaywallConsumed" });
}, [loginStatus, navigator, roomDispatch, unlockPaywallRequest]);
@@ -45,6 +45,8 @@ export function SidebarWalletCard({
<button
type="button"
data-analytics-key="sidebar.coins_rules"
data-analytics-label="Open coin usage rules"
className={styles.rulesButton}
onClick={onRulesClick}
>
@@ -56,13 +58,21 @@ export function SidebarWalletCard({
{onActivateVip ? (
<button
type="button"
data-analytics-key="sidebar.activate_vip"
data-analytics-label="Activate VIP"
className={styles.activateButton}
onClick={onActivateVip}
>
Activate VIP
</button>
) : null}
<button type="button" className={styles.topUpButton} onClick={onTopUp}>
<button
type="button"
data-analytics-key="sidebar.topup"
data-analytics-label="Top up credits"
className={styles.topUpButton}
onClick={onTopUp}
>
Top Up
</button>
</div>
@@ -80,6 +80,8 @@ export function UserHeader({
{state === "guest" && (
<button
type="button"
data-analytics-key="sidebar.login"
data-analytics-label="Open login"
className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[linear-gradient(90deg,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] px-(--spacing-lg,16px) py-[clamp(5px,1.111vw,6px)] text-(length:clamp(12px,2.593vw,14px)) font-semibold text-(--color-text-primary,#ffffff) hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--color-accent,#f84d96)"
onClick={onLoginClick}
aria-label="Log in"
+24 -3
View File
@@ -46,7 +46,11 @@ export function SidebarScreen() {
<div className={styles.bgOrbTwo} aria-hidden="true" />
<div className={styles.topBar}>
<BackButton href={ROUTES.chat} variant="soft" />
<BackButton
href={ROUTES.chat}
variant="soft"
analyticsKey="sidebar.back_to_chat"
/>
</div>
<section className={`${styles.userSlot} ${styles.revealOne}`}>
@@ -68,10 +72,25 @@ export function SidebarScreen() {
dailyFreePrivateRemaining={view.wallet.dailyFreePrivateRemaining}
onActivateVip={
view.canActivateVip
? () => navigator.openSubscription({ type: "vip" })
? () =>
navigator.openSubscription({
type: "vip",
analytics: {
entryPoint: "sidebar",
triggerReason: "vip_cta",
},
})
: undefined
}
onTopUp={() => navigator.openSubscription({ type: "topup" })}
onTopUp={() =>
navigator.openSubscription({
type: "topup",
analytics: {
entryPoint: "sidebar",
triggerReason: "sidebar_recharge",
},
})
}
onRulesClick={() => navigator.push(ROUTES.coinsRules)}
/>
</section>
@@ -103,6 +122,8 @@ export function SidebarScreen() {
<button
type="button"
data-analytics-key="sidebar.logout"
data-analytics-label="Log out"
className={styles.logoutCard}
onClick={handleLogoutClick}
>
@@ -46,6 +46,7 @@ describe("splash Tailwind components", () => {
expect(html).toContain("active:enabled:brightness-90");
expect(html).toContain("touch-manipulation");
expect(html).toContain("Start Chatting");
expect(html).toContain('data-analytics-key="splash.start_chat"');
expect(html).not.toContain("disabled=\"");
expect(html).not.toContain("animate-spin");
});
@@ -11,6 +11,8 @@ export function SplashButton({ onStartChat }: SplashButtonProps) {
<div className="z-2 flex w-full flex-row items-center justify-center gap-[clamp(var(--spacing-md,12px),4.815vw,var(--spacing-26,26px))] px-[clamp(var(--spacing-sm,8px),2.963vw,var(--spacing-lg,16px))]">
<button
type="button"
data-analytics-key="splash.start_chat"
data-analytics-label="Start chatting"
onClick={onStartChat}
className="inline-flex min-h-(--responsive-control-height,48px) w-full max-w-120 touch-manipulation flex-auto cursor-pointer items-center justify-center rounded-(--radius-full,999px) border-0 bg-[linear-gradient(to_right,var(--color-button-gradient-start),var(--color-button-gradient-end))] px-[clamp(var(--spacing-lg,16px),5.556vw,30px)] font-bold italic text-white shadow-[0_0_10px_rgba(248,89,168,0.3)] transition-[filter,box-shadow,transform] duration-150 ease-out [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-white enabled:hover:-translate-y-px enabled:hover:brightness-105 enabled:hover:shadow-[0_8px_22px_rgba(248,89,168,0.42)] active:enabled:translate-y-0.5 active:enabled:scale-96 active:enabled:brightness-90 active:enabled:shadow-[0_2px_6px_rgba(248,89,168,0.24)] motion-reduce:transition-none motion-reduce:active:enabled:transform-none disabled:cursor-not-allowed disabled:opacity-70"
>
@@ -22,6 +22,8 @@ export function SplashLatestMessage({
return (
<button
type="button"
data-analytics-key="splash.latest_message"
data-analytics-label="Open latest message"
className={styles.card}
onClick={onOpenChat}
aria-label="Open latest message from Elio"
-5
View File
@@ -18,11 +18,6 @@ import {
import { useSplashLatestMessage } from "./hooks/use-splash-latest-message";
import styles from "./components/splash-screen.module.css";
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
if (typeof window !== "undefined") {
pwaUtil.prepareInstallPrompt();
}
export function SplashScreen() {
const navigator = useAppNavigator();
const authState = useAuthState();
@@ -66,6 +66,9 @@ describe("subscription Tailwind components", () => {
/<button[^>]*aria-pressed="true"[^>]*>/,
)?.[0];
expect(selectedButton).toBeDefined();
expect(selectedButton).toContain(
'data-analytics-key="subscription.payment_method"',
);
expect(selectedButton).toContain("border-[#f657a0]");
expect(selectedButton).not.toContain("border-[rgba(246,87,160,0.18)]");
expect(selectedButton).toContain("bg-[#fff4f9]");
@@ -67,6 +67,8 @@ export function SubscriptionCheckoutButton({
<>
<SubscriptionCtaButton
type="button"
data-analytics-key="subscription.checkout"
data-analytics-label="Start subscription checkout"
disabled={disabled}
isLoading={isLoading}
onClick={handleClick}
@@ -147,6 +149,8 @@ function EzpayRedirectConfirmDialog({
</button>
<button
type="button"
data-analytics-key="subscription.external_checkout"
data-analytics-label="Continue to external checkout"
className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
@@ -40,6 +40,8 @@ export function SubscriptionCoinsOfferSection({
<button
key={plan.id}
type="button"
data-analytics-key="subscription.plan_select"
data-analytics-label="Select coin plan"
className={`${styles.row} ${selected ? styles.selected : ""}`}
aria-pressed={selected}
aria-label={`${plan.coins} Coins, ${plan.currency}${plan.price}`}
@@ -75,6 +75,8 @@ export function SubscriptionPaymentMethod({
<button
key={method.channel}
type="button"
data-analytics-key="subscription.payment_method"
data-analytics-label="Select payment method"
className={classes}
disabled={disabled}
aria-pressed={selected}
@@ -46,6 +46,8 @@ export function SubscriptionVipOfferSection({
<button
key={plan.id}
type="button"
data-analytics-key="subscription.plan_select"
data-analytics-label="Select VIP plan"
className={`${styles.planCard} ${selected ? styles.selected : ""}`}
aria-pressed={selected}
aria-label={`${plan.title}, ${plan.price} ${plan.currency}`}
@@ -3,6 +3,11 @@
import { useSearchParams } from "next/navigation";
import type { PayChannel } from "@/data/dto/payment";
import {
PAYMENT_ANALYTICS_ENTRY_PARAM,
PAYMENT_ANALYTICS_REASON_PARAM,
parsePaymentAnalyticsContext,
} from "@/lib/analytics";
import {
SubscriptionScreen,
@@ -28,6 +33,11 @@ export function SubscriptionPageClient() {
const shouldResumePendingOrder = searchParams.get("paymentReturn") === "1";
const returnTo = toReturnTo(searchParams.get("returnTo"));
const initialPayChannel = toPayChannel(searchParams.get("payChannel"));
const analyticsContext = parsePaymentAnalyticsContext({
entryPoint: searchParams.get(PAYMENT_ANALYTICS_ENTRY_PARAM),
triggerReason: searchParams.get(PAYMENT_ANALYTICS_REASON_PARAM),
subscriptionType,
});
return (
<SubscriptionScreen
@@ -35,6 +45,7 @@ export function SubscriptionPageClient() {
shouldResumePendingOrder={shouldResumePendingOrder}
returnTo={returnTo}
initialPayChannel={initialPayChannel}
analyticsContext={analyticsContext}
/>
);
}
+31 -12
View File
@@ -3,8 +3,14 @@ import { useEffect, useMemo, useRef } from "react";
import { BackButton } from "@/app/_components";
import { Checkbox, MobileShell } from "@/app/_components/core";
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import { AppConstants } from "@/core/app_constants";
import type { PayChannel } from "@/data/dto/payment";
import {
behaviorAnalytics,
getDefaultPaymentAnalyticsContext,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
import { useUserState } from "@/stores/user/user-context";
import {
@@ -34,6 +40,7 @@ export interface SubscriptionScreenProps {
shouldResumePendingOrder?: boolean;
returnTo?: "chat" | null;
initialPayChannel?: PayChannel | null;
analyticsContext?: PaymentAnalyticsContext;
}
export function SubscriptionScreen({
@@ -41,6 +48,7 @@ export function SubscriptionScreen({
shouldResumePendingOrder = false,
returnTo = null,
initialPayChannel = null,
analyticsContext: providedAnalyticsContext,
}: SubscriptionScreenProps) {
const userState = useUserState();
const countryCode = userState.currentUser?.countryCode;
@@ -64,6 +72,9 @@ export function SubscriptionScreen({
initialPayChannel: resolvedInitialPayChannel,
});
const canSubscribeVip = subscriptionType === "vip";
const analyticsContext =
providedAnalyticsContext ??
getDefaultPaymentAnalyticsContext(subscriptionType);
const vipOfferPlans = useMemo(
() => toVipOfferPlanViews(payment.plans),
@@ -73,6 +84,17 @@ export function SubscriptionScreen({
() => toCoinsOfferPlanViews(payment.plans),
[payment.plans],
);
const displayedPlans = useMemo(() => {
const displayedPlanIds = [
...(canSubscribeVip ? vipOfferPlans : []),
...directCoinsPlans,
].map((plan) => plan.id);
return displayedPlanIds.flatMap((planId) => {
const plan = payment.plans.find((item) => item.planId === planId);
return plan ? [plan] : [];
});
}, [canSubscribeVip, directCoinsPlans, payment.plans, vipOfferPlans]);
usePaymentPlanAnalytics(displayedPlans, analyticsContext);
const firstRechargeOffer = useMemo(
() =>
getFirstRechargeOfferView({
@@ -95,6 +117,12 @@ export function SubscriptionScreen({
const canActivate =
selectedPlan !== null && payment.agreed && !isPaymentBusy;
const handleSelectPlan = (planId: string) => {
const plan = payment.plans.find((item) => item.planId === planId);
if (plan) behaviorAnalytics.planClick(plan, analyticsContext);
paymentDispatch({ type: "PaymentPlanSelected", planId });
};
useEffect(() => {
if (isPaymentBusy) return;
@@ -160,6 +188,7 @@ export function SubscriptionScreen({
className={styles.backSlot}
onClick={handleBackClick}
variant="soft"
analyticsKey="subscription.back"
/>
</header>
@@ -187,23 +216,13 @@ export function SubscriptionScreen({
<SubscriptionVipOfferSection
plans={vipOfferPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
onSelectPlan={handleSelectPlan}
/>
) : null}
<SubscriptionCoinsOfferSection
plans={directCoinsPlans}
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
onSelectPlan={(planId) =>
paymentDispatch({
type: "PaymentPlanSelected",
planId,
})
}
onSelectPlan={handleSelectPlan}
/>
</div>
+4
View File
@@ -54,6 +54,8 @@ export function TipCheckoutButton({
<>
<button
type="button"
data-analytics-key="tip.checkout"
data-analytics-label="Buy coffee tip"
className={styles.checkoutButton}
disabled={disabled || isLoading}
onClick={onOrder}
@@ -127,6 +129,8 @@ function EzpayRedirectConfirmDialog({
</button>
<button
type="button"
data-analytics-key="tip.external_checkout"
data-analytics-label="Continue to external checkout"
className={`${dialogStyles.button} ${dialogStyles.primary}`}
disabled={isConfirming}
onClick={onConfirm}
+16
View File
@@ -7,7 +7,12 @@ import { ArrowLeft, Heart, Sparkles } from "lucide-react";
import { CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core";
import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle";
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import type { PayChannel } from "@/data/dto/payment";
import {
behaviorAnalytics,
type PaymentAnalyticsContext,
} from "@/lib/analytics";
import {
buildTipCoffeePath,
DEFAULT_TIP_COFFEE_TYPE,
@@ -29,6 +34,11 @@ import {
} from "./tip-screen.helpers";
import styles from "./tip-screen.module.css";
const TIP_ANALYTICS_CONTEXT: PaymentAnalyticsContext = {
entryPoint: "tip_page",
triggerReason: "ad_landing",
};
export interface TipScreenProps {
coffeeType?: TipCoffeeType;
shouldResumePendingOrder?: boolean;
@@ -55,6 +65,7 @@ export function TipScreen({
[coffeeType, payment.plans],
);
const priceLabel = formatTipPrice(coffeePlan, coffeeType);
usePaymentPlanAnalytics(coffeePlan ? [coffeePlan] : [], TIP_ANALYTICS_CONTEXT);
const isPaymentBusy =
payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid;
const canCreateOrder =
@@ -136,6 +147,9 @@ export function TipScreen({
const handleOrder = () => {
if (isAuthLoading) return;
if (coffeePlan) {
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
}
if (!isRealLoginStatus(authState.loginStatus)) {
navigator.openAuth(returnPath);
return;
@@ -163,6 +177,8 @@ export function TipScreen({
<header className={styles.header}>
<button
type="button"
data-analytics-key="tip.back"
data-analytics-label="Go back from tip"
className={styles.backButton}
aria-label="Go back"
onClick={handleBack}
@@ -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 {
getPaymentUrl,
getStripeClientSecret,
isEzpayPayment,
launchEzpayRedirect,
} from "../payment_launch";
describe("payment launch helpers", () => {
@@ -40,4 +41,22 @@ describe("payment launch helpers", () => {
}),
).toBeNull();
});
it("reports a launch failure before redirecting without an order id", async () => {
const onOpened = vi.fn();
const onFailed = vi.fn();
await launchEzpayRedirect({
orderId: null,
paymentUrl: "https://pay.example/checkout?token=secret",
subscriptionType: "topup",
onOpened,
onFailed,
});
expect(onOpened).not.toHaveBeenCalled();
expect(onFailed).toHaveBeenCalledWith(
"Missing order id before opening Ezpay.",
);
});
});
+8 -1
View File
@@ -64,6 +64,7 @@ export interface LaunchEzpayRedirectInput {
subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType;
returnTo?: PendingPaymentReturnTo;
onOpened?: () => void;
onFailed: (errorMessage: string) => void;
}
@@ -73,6 +74,7 @@ export async function launchEzpayRedirect({
subscriptionType,
tipCoffeeType,
returnTo,
onOpened,
onFailed,
}: LaunchEzpayRedirectInput): Promise<void> {
log.debug("[payment-launch] launchEzpayRedirect START", {
@@ -121,5 +123,10 @@ export async function launchEzpayRedirect({
subscriptionType,
paymentUrl,
});
window.location.href = paymentUrl;
try {
window.location.href = paymentUrl;
onOpened?.();
} catch {
onFailed("Could not open Ezpay. Please try again.");
}
}
@@ -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");
});
it("preserves payment analytics context through auth redirects", () => {
const subscriptionUrl = ROUTE_BUILDERS.subscription("topup", {
payChannel: "stripe",
returnTo: "chat",
analytics: {
entryPoint: "chat_unlock",
triggerReason: "ad_landing",
},
});
expect(subscriptionUrl).toBe(
"/subscription?type=topup&payChannel=stripe&returnTo=chat&analytics_entry=chat_unlock&analytics_reason=ad_landing",
);
expect(
resolveAuthenticatedNavigation({
loginStatus: "guest",
targetUrl: subscriptionUrl,
}),
).toBe(ROUTE_BUILDERS.authWithRedirect(subscriptionUrl));
});
it("allows not logged in users to enter chat for guest bootstrap", () => {
expect(
resolveRouteGuardRedirect({
+3
View File
@@ -1,4 +1,5 @@
import type { PayChannel } from "@/data/dto/payment";
import type { PaymentAnalyticsContext } from "@/lib/analytics/payment_analytics_context";
import type {
PendingChatUnlockKind,
PendingChatPromotion,
@@ -13,6 +14,7 @@ export interface OpenSubscriptionInput {
payChannel?: PayChannel;
returnTo?: AppSubscriptionReturnTo;
replace?: boolean;
analytics?: PaymentAnalyticsContext;
}
export interface StartMessageUnlockInput {
@@ -37,4 +39,5 @@ export interface OpenSubscriptionForPendingUnlockInput {
returnUrl: string;
type: AppSubscriptionType;
payChannel?: PayChannel;
analytics?: PaymentAnalyticsContext;
}
+20 -1
View File
@@ -8,6 +8,11 @@
*/
import type { PayChannel } from "@/data/dto/payment";
import {
PAYMENT_ANALYTICS_ENTRY_PARAM,
PAYMENT_ANALYTICS_REASON_PARAM,
type PaymentAnalyticsContext,
} from "@/lib/analytics/payment_analytics_context";
/** 静态路由字面量 */
export const ROUTES = {
@@ -29,11 +34,25 @@ export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
export const ROUTE_BUILDERS = {
subscription: (
type: "vip" | "topup",
options: { payChannel?: PayChannel; returnTo?: "chat" } = {},
options: {
payChannel?: PayChannel;
returnTo?: "chat";
analytics?: PaymentAnalyticsContext;
} = {},
): `/subscription?${string}` => {
const params = new URLSearchParams({ type });
if (options.payChannel) params.set("payChannel", options.payChannel);
if (options.returnTo) params.set("returnTo", options.returnTo);
if (options.analytics) {
params.set(
PAYMENT_ANALYTICS_ENTRY_PARAM,
options.analytics.entryPoint,
);
params.set(
PAYMENT_ANALYTICS_REASON_PARAM,
options.analytics.triggerReason,
);
}
return `${ROUTES.subscription}?${params.toString()}` as const;
},
authWithRedirect: (redirectTo: string): `/auth?redirect=${string}` =>
+10
View File
@@ -10,6 +10,10 @@ import {
peekSubscriptionExplicitExitUrl,
} from "@/lib/navigation/subscription_exit";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import {
behaviorAnalytics,
getDefaultPaymentAnalyticsContext,
} from "@/lib/analytics";
import { useAuthSelector } from "@/stores/auth/auth-context";
import { useUserSelector } from "@/stores/user/user-context";
@@ -88,12 +92,16 @@ export function useAppNavigator(): AppNavigator {
payChannel = getDefaultPayChannel(),
returnTo = null,
replace: shouldReplace = false,
analytics = getDefaultPaymentAnalyticsContext(type),
}: OpenSubscriptionInput): void => {
const target = ROUTE_BUILDERS.subscription(type, {
payChannel,
returnTo: returnTo ?? undefined,
analytics,
});
behaviorAnalytics.rechargeModalOpen(analytics);
const nextUrl = resolveAuthenticatedNavigation({
loginStatus,
targetUrl: target,
@@ -153,6 +161,7 @@ export function useAppNavigator(): AppNavigator {
returnUrl,
type,
payChannel = getDefaultPayChannel(),
analytics,
}: OpenSubscriptionForPendingUnlockInput): void => {
void (async () => {
await NavigationStorage.savePendingChatUnlock({
@@ -169,6 +178,7 @@ export function useAppNavigator(): AppNavigator {
type,
payChannel,
returnTo: "chat",
analytics,
});
})();
},
@@ -7,6 +7,7 @@ import {
PaymentOrderStatusResponse,
PaymentPlansResponse,
} from "@/data/dto/payment";
import { behaviorAnalytics } from "@/lib/analytics";
import { paymentMachine } from "@/stores/payment/payment-machine";
import {
MAX_ORDER_POLLING_MS,
@@ -94,6 +95,7 @@ const tipPlan = {
function createTestPaymentMachine(
overrides: Partial<{
createOrderSpy: CreateOrderSpy;
createOrderError: Error;
orderStatus: "pending" | "paid" | "failed";
orderStatuses: Array<"pending" | "paid" | "failed">;
}> = {},
@@ -124,6 +126,7 @@ function createTestPaymentMachine(
CreateOrderInput
>(async ({ input }) => {
createOrderSpy(input);
if (overrides.createOrderError) throw overrides.createOrderError;
return CreatePaymentOrderResponse.from({
orderId: "pay_test_001",
payParams: {
@@ -152,6 +155,7 @@ function createTestPaymentMachine(
describe("paymentMachine", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("loads plans and selects the default monthly VIP plan", async () => {
@@ -460,6 +464,18 @@ describe("paymentMachine", () => {
});
it("creates an order, polls it, and reaches paid state", async () => {
const createOrderStart = vi
.spyOn(behaviorAnalytics, "createOrderStart")
.mockImplementation(() => undefined);
const createOrderSuccess = vi
.spyOn(behaviorAnalytics, "createOrderSuccess")
.mockImplementation(() => undefined);
const statusPollStart = vi
.spyOn(behaviorAnalytics, "statusPollStart")
.mockImplementation(() => undefined);
const statusPollUpdate = vi
.spyOn(behaviorAnalytics, "statusPollUpdate")
.mockImplementation(() => undefined);
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
@@ -481,6 +497,50 @@ describe("paymentMachine", () => {
expect(context.orderStatus).toBe("paid");
expect(context.orderPollingStartedAt).toBeNull();
expect(context.launchNonce).toBe(1);
expect(createOrderStart).toHaveBeenCalledWith(
expect.objectContaining({ planId: "vip_monthly" }),
"stripe",
);
expect(createOrderSuccess).toHaveBeenCalledWith(
expect.objectContaining({ planId: "vip_monthly" }),
"pay_test_001",
"stripe",
);
expect(statusPollStart).toHaveBeenCalledWith(
"pay_test_001",
expect.objectContaining({ planId: "vip_monthly" }),
);
expect(statusPollUpdate).toHaveBeenCalledWith(
"pay_test_001",
"paid",
expect.objectContaining({ planId: "vip_monthly" }),
);
actor.stop();
});
it("tracks order creation failures without interrupting failure handling", async () => {
const createOrderFailed = vi
.spyOn(behaviorAnalytics, "createOrderFailed")
.mockImplementation(() => undefined);
const actor = createActor(
createTestPaymentMachine({
createOrderError: new Error("backend unavailable"),
}),
).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("failed"));
expect(createOrderFailed).toHaveBeenCalledWith(
expect.objectContaining({ planId: "vip_monthly" }),
"stripe",
);
expect(actor.getSnapshot().context.errorMessage).toBe(
"backend unavailable",
);
actor.stop();
});
@@ -533,6 +593,9 @@ describe("paymentMachine", () => {
});
it("moves to failed state when a pending order exceeds polling timeout", async () => {
const statusPollTimeout = vi
.spyOn(behaviorAnalytics, "statusPollTimeout")
.mockImplementation(() => undefined);
const now = new Date("2026-06-30T00:00:00.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
@@ -555,6 +618,10 @@ describe("paymentMachine", () => {
expect(context.orderStatus).toBe("failed");
expect(context.orderPollingStartedAt).toBeNull();
expect(context.errorMessage).toBe(PAYMENT_TIMEOUT_ERROR_MESSAGE);
expect(statusPollTimeout).toHaveBeenCalledWith(
"pay_test_001",
expect.objectContaining({ planId: "vip_monthly" }),
);
actor.stop();
});
@@ -179,6 +179,7 @@ export function selectPlanState(
export function resetOrderState(): Partial<PaymentState> {
return {
currentOrderId: null,
currentOrderPlanId: null,
payParams: null,
orderStatus: null,
orderPollingStartedAt: null,
+120 -37
View File
@@ -3,6 +3,7 @@
*/
import { assign, setup } from "xstate";
import { behaviorAnalytics } from "@/lib/analytics";
import type { PaymentEvent } from "./payment-events";
import { initialState, type PaymentState } from "./payment-state";
import {
@@ -28,6 +29,19 @@ export { initialState } from "./payment-state";
const POLL_DELAY_MS = 4000;
function getSelectedPlan(context: PaymentState) {
return context.plans.find(
(plan) => plan.planId === context.selectedPlanId,
);
}
function getCurrentOrderPlan(context: PaymentState) {
if (!context.currentOrderPlanId) return undefined;
return context.plans.find(
(plan) => plan.planId === context.currentOrderPlanId,
);
}
export const paymentMachine = setup({
types: {
context: {} as PaymentState,
@@ -192,6 +206,10 @@ export const paymentMachine = setup({
},
creatingOrder: {
entry: ({ context }) => {
const plan = getSelectedPlan(context);
if (plan) behaviorAnalytics.createOrderStart(plan, context.payChannel);
},
invoke: {
src: "createOrder",
input: ({ context }) => ({
@@ -201,20 +219,45 @@ export const paymentMachine = setup({
}),
onDone: {
target: "pollingOrder",
actions: assign(({ context, event }) => ({
currentOrderId: event.output.orderId,
payParams: event.output.payParams,
orderStatus: "pending",
orderPollingStartedAt: Date.now(),
errorMessage: null,
launchNonce: context.launchNonce + 1,
})),
actions: [
({ context, event }) => {
const plan = getSelectedPlan(context);
if (plan) {
behaviorAnalytics.createOrderSuccess(
plan,
event.output.orderId,
context.payChannel,
);
}
behaviorAnalytics.statusPollStart(
event.output.orderId,
plan,
);
},
assign(({ context, event }) => ({
currentOrderId: event.output.orderId,
currentOrderPlanId: context.selectedPlanId,
payParams: event.output.payParams,
orderStatus: "pending",
orderPollingStartedAt: Date.now(),
errorMessage: null,
launchNonce: context.launchNonce + 1,
})),
],
},
onError: {
target: "failed",
actions: assign(({ event }) => ({
errorMessage: toPaymentErrorMessage(event.error),
})),
actions: [
({ context }) => {
const plan = getSelectedPlan(context);
if (plan) {
behaviorAnalytics.createOrderFailed(plan, context.payChannel);
}
},
assign(({ event }) => ({
errorMessage: toPaymentErrorMessage(event.error),
})),
],
},
},
},
@@ -229,37 +272,73 @@ export const paymentMachine = setup({
{
guard: ({ event }) => event.output.status === "paid",
target: "paid",
actions: assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: null,
})),
actions: [
({ context, event }) =>
behaviorAnalytics.statusPollUpdate(
context.currentOrderId ?? "",
event.output.status,
getCurrentOrderPlan(context),
),
assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: null,
})),
],
},
{
guard: ({ event }) => event.output.status === "failed",
target: "failed",
actions: assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: "Payment failed or was cancelled.",
})),
actions: [
({ context, event }) =>
behaviorAnalytics.statusPollUpdate(
context.currentOrderId ?? "",
event.output.status,
getCurrentOrderPlan(context),
),
assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: "Payment failed or was cancelled.",
})),
],
},
{
guard: ({ context }) =>
hasOrderPollingTimedOut(context.orderPollingStartedAt),
target: "failed",
actions: assign({
orderStatus: "failed",
orderPollingStartedAt: null,
errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE,
}),
actions: [
({ context, event }) => {
const orderId = context.currentOrderId ?? "";
const plan = getCurrentOrderPlan(context);
behaviorAnalytics.statusPollUpdate(
orderId,
event.output.status,
plan,
);
behaviorAnalytics.statusPollTimeout(orderId, plan);
},
assign({
orderStatus: "failed",
orderPollingStartedAt: null,
errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE,
}),
],
},
{
target: "waitingForPayment",
actions: assign(({ event }) => ({
orderStatus: event.output.status,
errorMessage: null,
})),
actions: [
({ context, event }) =>
behaviorAnalytics.statusPollUpdate(
context.currentOrderId ?? "",
event.output.status,
getCurrentOrderPlan(context),
),
assign(({ event }) => ({
orderStatus: event.output.status,
errorMessage: null,
})),
],
},
],
onError: {
@@ -354,13 +433,17 @@ export const paymentMachine = setup({
},
PaymentReturned: {
target: ".pollingOrder",
actions: assign(({ event }) => ({
currentOrderId: event.orderId,
payParams: null,
orderStatus: "pending",
orderPollingStartedAt: event.createdAt ?? Date.now(),
errorMessage: null,
})),
actions: [
({ event }) => behaviorAnalytics.statusPollStart(event.orderId),
assign(({ event }) => ({
currentOrderId: event.orderId,
currentOrderPlanId: null,
payParams: null,
orderStatus: "pending",
orderPollingStartedAt: event.createdAt ?? Date.now(),
errorMessage: null,
})),
],
},
PaymentLaunchFailed: {
target: ".failed",
+2
View File
@@ -18,6 +18,7 @@ export interface PaymentState {
autoRenew: boolean;
agreed: boolean;
currentOrderId: string | null;
currentOrderPlanId: string | null;
payParams: Record<string, unknown> | null;
orderStatus: PaymentOrderStatus | null;
orderPollingStartedAt: number | null;
@@ -34,6 +35,7 @@ export const initialState: PaymentState = {
autoRenew: true,
agreed: true,
currentOrderId: null,
currentOrderPlanId: null,
payParams: null,
orderStatus: null,
orderPollingStartedAt: null,