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}
|
||||
|
||||
Reference in New Issue
Block a user