feat(analytics): add behavior and payment funnel tracking
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user