diff --git a/src/app/_hooks/use-payment-route-flow.ts b/src/app/_hooks/use-payment-route-flow.ts index 3172b323..7aad9a25 100644 --- a/src/app/_hooks/use-payment-route-flow.ts +++ b/src/app/_hooks/use-payment-route-flow.ts @@ -23,6 +23,8 @@ export interface UsePaymentRouteFlowInput { initialCategory?: string | null; initialPlanId?: string | null; commercialOfferId?: string | null; + resumeOrderId?: string | null; + chatActionId?: string | null; } export interface PaymentRouteFlow { @@ -43,16 +45,20 @@ export function usePaymentRouteFlow({ initialCategory = null, initialPlanId = null, commercialOfferId = null, + resumeOrderId = null, + chatActionId = null, }: UsePaymentRouteFlowInput): PaymentRouteFlow { const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); const initializedCatalogKeyRef = useRef(null); + const resumedOrderIdRef = useRef(null); const catalogKey = [ catalog, characterId ?? "", initialCategory ?? "", initialPlanId ?? "", commercialOfferId ?? "", + chatActionId ?? "", ].join(":"); usePendingPaymentOrderLifecycle({ @@ -73,17 +79,25 @@ export function usePaymentRouteFlow({ ...(initialCategory ? { category: initialCategory } : {}), ...(initialPlanId ? { planId: initialPlanId } : {}), ...(commercialOfferId ? { commercialOfferId } : {}), + ...(chatActionId ? { chatActionId } : {}), }); }, [ catalog, catalogKey, characterId, commercialOfferId, + chatActionId, initialCategory, initialPlanId, initialPayChannel, paymentDispatch, ]); + useEffect(() => { + if (!resumeOrderId || resumedOrderIdRef.current === resumeOrderId) return; + resumedOrderIdRef.current = resumeOrderId; + paymentDispatch({ type: "PaymentReturned", orderId: resumeOrderId }); + }, [paymentDispatch, resumeOrderId]); + return { payment, paymentDispatch }; } diff --git a/src/app/characters/[characterSlug]/tip/page.tsx b/src/app/characters/[characterSlug]/tip/page.tsx index c8901cd0..77e8a4f4 100644 --- a/src/app/characters/[characterSlug]/tip/page.tsx +++ b/src/app/characters/[characterSlug]/tip/page.tsx @@ -23,6 +23,7 @@ export default async function CharacterTipPage({ const initialPlanId = normalizeTipGiftParam( getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]), ); + const chatActionId = getFirstPaymentSearchParam(query.chatActionId); return ( ); } diff --git a/src/app/chat/__tests__/chat-action-navigation.test.ts b/src/app/chat/__tests__/chat-action-navigation.test.ts new file mode 100644 index 00000000..f960addb --- /dev/null +++ b/src/app/chat/__tests__/chat-action-navigation.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ChatAction } from "@/data/schemas/chat"; +import type { PaymentOrderStatusResponse } from "@/data/schemas/payment"; + +import { + resolveChatActionTarget, + type ChatActionNavigationContext, +} from "../chat-action-navigation"; + +const baseAction: ChatAction = { + actionId: "action-1", + kind: "support", + type: "openProfile", + copy: "Open it here.", + ctaLabel: "Open", + ruleId: null, + orderId: null, +}; + +function createContext( + order: Partial = {}, +): ChatActionNavigationContext { + const orderResponse: PaymentOrderStatusResponse = { + orderId: "order-1", + status: "pending", + orderType: "dol", + planId: "topup-100", + creditsAdded: 0, + ...order, + }; + return { + characterRoutes: { + splash: "/characters/elio/splash", + chat: "/characters/elio/chat", + call: "/characters/elio/call", + privateZone: "/characters/elio/private-zone", + tip: "/characters/elio/tip", + }, + characterSlug: "elio", + profileUrl: "/profile?returnTo=chat", + feedbackUrl: "/feedback?returnTo=chat", + coinsRulesUrl: "/coins-rules?returnTo=chat", + getOrderStatus: vi.fn(async () => orderResponse), + }; +} + +describe("resolveChatActionTarget", () => { + it.each([ + ["giftOffer", "/characters/elio/tip?chatActionId=action-1"], + ["privateAlbumOffer", "/characters/elio/private-zone"], + ["openProfile", "/profile?returnTo=chat"], + ["openWallet", "/profile?returnTo=chat"], + ["openCoinsRules", "/coins-rules?returnTo=chat"], + ["openFeedback", "/feedback?returnTo=chat"], + ] as const)("maps %s to an allowlisted internal page", async (type, expected) => { + await expect( + resolveChatActionTarget({ ...baseAction, type }, createContext()), + ).resolves.toBe(expected); + }); + + it("maps VIP and top-up actions to typed subscription routes", async () => { + await expect( + resolveChatActionTarget( + { ...baseAction, kind: "commercial", type: "activateVip" }, + createContext(), + ), + ).resolves.toBe( + "/subscription?type=vip&returnTo=chat&character=elio&chatActionId=action-1", + ); + await expect( + resolveChatActionTarget( + { ...baseAction, kind: "commercial", type: "topUp" }, + createContext(), + ), + ).resolves.toBe( + "/subscription?type=topup&returnTo=chat&character=elio&chatActionId=action-1", + ); + }); + + it("resumes a pending VIP order using its owned order id", async () => { + const action: ChatAction = { + ...baseAction, + type: "resumePayment", + orderId: "order-1", + }; + await expect( + resolveChatActionTarget( + action, + createContext({ orderType: "vip_monthly" }), + ), + ).resolves.toBe( + "/subscription?type=vip&returnTo=chat&character=elio&resumeOrderId=order-1", + ); + }); + + it("does not resume an order that is no longer pending", async () => { + const action: ChatAction = { + ...baseAction, + type: "resumePayment", + orderId: "order-1", + }; + await expect( + resolveChatActionTarget(action, createContext({ status: "paid" })), + ).resolves.toBe("/profile?returnTo=chat"); + await expect( + resolveChatActionTarget(action, createContext({ status: "expired" })), + ).resolves.toBe("/feedback?returnTo=chat"); + }); +}); diff --git a/src/app/chat/chat-action-navigation.ts b/src/app/chat/chat-action-navigation.ts new file mode 100644 index 00000000..a7ccb14a --- /dev/null +++ b/src/app/chat/chat-action-navigation.ts @@ -0,0 +1,64 @@ +import type { ChatAction } from "@/data/schemas/chat"; +import type { PaymentOrderStatusResponse } from "@/data/schemas/payment"; +import type { CharacterRoutes } from "@/router/routes"; +import { appendRouteSearchParams, ROUTE_BUILDERS } from "@/router/routes"; + +export interface ChatActionNavigationContext { + characterRoutes: CharacterRoutes; + characterSlug: string; + profileUrl: string; + feedbackUrl: string; + coinsRulesUrl: string; + getOrderStatus: (orderId: string) => Promise; +} + +/** Resolve only application-owned, allowlisted destinations for a chat action. */ +export async function resolveChatActionTarget( + action: ChatAction, + context: ChatActionNavigationContext, +): Promise { + switch (action.type) { + case "giftOffer": + return appendRouteSearchParams(context.characterRoutes.tip, { + chatActionId: action.actionId, + }); + case "privateAlbumOffer": + return context.characterRoutes.privateZone; + case "openProfile": + case "openWallet": + return context.profileUrl; + case "openCoinsRules": + return context.coinsRulesUrl; + case "activateVip": + return ROUTE_BUILDERS.subscription("vip", { + sourceCharacterSlug: context.characterSlug, + returnTo: "chat", + chatActionId: action.actionId, + }); + case "topUp": + return ROUTE_BUILDERS.subscription("topup", { + sourceCharacterSlug: context.characterSlug, + returnTo: "chat", + chatActionId: action.actionId, + }); + case "openFeedback": + return context.feedbackUrl; + case "resumePayment": { + if (!action.orderId) return context.feedbackUrl; + const order = await context.getOrderStatus(action.orderId); + if (order.status === "paid") return context.profileUrl; + if (order.status !== "pending") return context.feedbackUrl; + if (order.orderType === "tip") return context.characterRoutes.tip; + return ROUTE_BUILDERS.subscription( + order.orderType === "vip" || order.orderType.startsWith("vip_") + ? "vip" + : "topup", + { + sourceCharacterSlug: context.characterSlug, + returnTo: "chat", + resumeOrderId: action.orderId, + }, + ); + } + } +} diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 975038fd..e75a8a75 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -18,9 +18,13 @@ import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session import { buildGlobalPageUrl } from "@/router/global-route-context"; import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver"; import { getCharacterRoutes, ROUTE_BUILDERS, ROUTES } from "@/router/routes"; -import type { CommercialAction } from "@/data/schemas/chat"; +import type { ChatAction, CommercialAction } from "@/data/schemas/chat"; import { paymentApi } from "@/data/services/api"; import { behaviorAnalytics } from "@/lib/analytics"; +import { + recordChatActionEvent, + rememberChatActionArrival, +} from "@/lib/chat/chat_action_events"; import { MobileShell } from "@/app/_components/core"; @@ -48,6 +52,7 @@ import { useChatGuestLogin } from "./hooks/use-chat-guest-login"; import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner"; import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap"; import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync"; +import { resolveChatActionTarget } from "./chat-action-navigation"; const chatShellStyle = { "--chat-message-font-size": "clamp(16px, 3.333vw, 18px)", @@ -65,6 +70,14 @@ export function ChatScreen() { ROUTES.profile, characterRoutes.chat, ); + const feedbackUrl = buildGlobalPageUrl( + ROUTES.feedback, + characterRoutes.chat, + ); + const coinsRulesUrl = buildGlobalPageUrl( + ROUTES.coinsRules, + characterRoutes.chat, + ); const searchParams = useSearchParams(); const state = useChatState(); const chatDispatch = useChatDispatch(); @@ -325,6 +338,69 @@ export function ChatScreen() { ); } + function handleChatActionViewed(action: ChatAction): void { + void recordChatActionEvent(action, state.characterId, "viewed").catch( + () => undefined, + ); + } + + async function handleChatAction(action: ChatAction): Promise { + behaviorAnalytics.elementClick( + "chat.action.open", + action.ctaLabel, + { + actionId: action.actionId, + actionKind: action.kind, + actionType: action.type, + characterId: state.characterId, + ruleId: action.ruleId, + }, + ); + await recordChatActionEvent( + action, + state.characterId, + "opened", + ).catch(() => undefined); + + const targetUrl = await resolveChatActionTarget(action, { + characterRoutes, + characterSlug: character.slug, + profileUrl, + feedbackUrl, + coinsRulesUrl, + getOrderStatus: (orderId) => paymentApi.getOrderStatus(orderId), + }); + const authenticatedTarget = resolveAuthenticatedNavigation({ + loginStatus: authState.loginStatus, + targetUrl, + }); + rememberChatActionArrival( + action, + state.characterId, + targetUrl, + ); + router.push(authenticatedTarget); + } + + function handleChatActionDismiss(action: ChatAction): void { + behaviorAnalytics.elementClick( + "chat.action.dismiss", + "Dismiss chat action", + { + actionId: action.actionId, + actionKind: action.kind, + actionType: action.type, + characterId: state.characterId, + ruleId: action.ruleId, + }, + ); + void recordChatActionEvent( + action, + state.characterId, + "dismissed", + ).catch(() => undefined); + } + return (
{ chatDispatch({ type: "ChatLoadMoreHistoryRequested" }); }} diff --git a/src/app/chat/components/__tests__/chat-action-card.test.tsx b/src/app/chat/components/__tests__/chat-action-card.test.tsx new file mode 100644 index 00000000..49d42d54 --- /dev/null +++ b/src/app/chat/components/__tests__/chat-action-card.test.tsx @@ -0,0 +1,67 @@ +/* @vitest-environment jsdom */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ChatActionCard } from "../chat-action-card"; + +describe("ChatActionCard", () => { + 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.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("reports one view, opens the action and can be dismissed", async () => { + const onViewed = vi.fn(); + const onActivate = vi.fn(async () => undefined); + const onDismiss = vi.fn(); + const action = { + actionId: "action-1", + kind: "support" as const, + type: "openFeedback" as const, + copy: "Share the screenshot and approximate payment time here.", + ctaLabel: "Open Feedback", + ruleId: null, + orderId: null, + }; + + await act(async () => { + root.render( + , + ); + }); + expect(onViewed).toHaveBeenCalledTimes(1); + expect(container.querySelector("[data-chat-action='openFeedback']")) + .not.toBeNull(); + + const openButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Open Feedback"), + ); + await act(async () => openButton?.click()); + expect(onActivate).toHaveBeenCalledWith(action); + + const dismissButton = container.querySelector( + 'button[aria-label="Dismiss"]', + ); + act(() => dismissButton?.click()); + expect(onDismiss).toHaveBeenCalledWith(action); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/src/app/chat/components/chat-action-card.tsx b/src/app/chat/components/chat-action-card.tsx new file mode 100644 index 00000000..82ef465d --- /dev/null +++ b/src/app/chat/components/chat-action-card.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + ArrowRight, + CircleDollarSign, + CircleHelp, + Gift, + Images, + LoaderCircle, + MessageSquareWarning, + UserRound, + WalletCards, + X, + type LucideIcon, +} from "lucide-react"; + +import type { ChatAction } from "@/data/schemas/chat"; + +import styles from "./chat-area.module.css"; + +const ACTION_ICONS: Record = { + giftOffer: Gift, + privateAlbumOffer: Images, + openProfile: UserRound, + openWallet: WalletCards, + openCoinsRules: WalletCards, + activateVip: CircleDollarSign, + topUp: CircleDollarSign, + openFeedback: MessageSquareWarning, + resumePayment: CircleHelp, +}; + +export interface ChatActionCardProps { + action: ChatAction; + onViewed?: (action: ChatAction) => void; + onActivate?: (action: ChatAction) => void | Promise; + onDismiss?: (action: ChatAction) => void; +} + +export function ChatActionCard({ + action, + onViewed, + onActivate, + onDismiss, +}: ChatActionCardProps) { + const [dismissed, setDismissed] = useState(false); + const [activating, setActivating] = useState(false); + const [activationError, setActivationError] = useState(null); + const viewedActionIdRef = useRef(null); + + useEffect(() => { + if (viewedActionIdRef.current === action.actionId) return; + viewedActionIdRef.current = action.actionId; + onViewed?.(action); + }, [action, onViewed]); + + if (dismissed) return null; + const Icon = ACTION_ICONS[action.type]; + + return ( + + ); +} diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 39ada075..73313964 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -24,7 +24,7 @@ import { import { LoaderCircle } from "lucide-react"; import type { UiMessage } from "@/stores/chat/ui-message"; -import type { CommercialAction } from "@/data/schemas/chat"; +import type { ChatAction, CommercialAction } from "@/data/schemas/chat"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { @@ -64,6 +64,9 @@ export interface ChatAreaProps { onCharacterAvatarClick?: () => void; onCommercialAction?: (action: CommercialAction) => void; onCommercialActionDismiss?: (action: CommercialAction) => void; + onChatActionViewed?: (action: ChatAction) => void; + onChatAction?: (action: ChatAction) => void | Promise; + onChatActionDismiss?: (action: ChatAction) => void; } type ChatMessageAction = ( @@ -90,6 +93,9 @@ export function ChatArea({ onCharacterAvatarClick, onCommercialAction, onCommercialActionDismiss, + onChatActionViewed, + onChatAction, + onChatActionDismiss, }: ChatAreaProps) { const scrollRef = useRef(null); const contentRef = useRef(null); @@ -264,6 +270,9 @@ export function ChatArea({ onCharacterAvatarClick, onCommercialAction, onCommercialActionDismiss, + onChatActionViewed, + onChatAction, + onChatActionDismiss, )} {isReplyingAI ? ( @@ -304,6 +313,9 @@ function renderMessagesWithDateHeaders( onCharacterAvatarClick?: () => void, onCommercialAction?: (action: CommercialAction) => void, onCommercialActionDismiss?: (action: CommercialAction) => void, + onChatActionViewed?: (action: ChatAction) => void, + onChatAction?: (action: ChatAction) => void | Promise, + onChatActionDismiss?: (action: ChatAction) => void, ) { return buildChatRenderItems(messages, getMessageKey).map((item) => item.type === "date" ? ( @@ -324,6 +336,7 @@ function renderMessagesWithDateHeaders( lockedPrivate={item.message.lockedPrivate} privateMessageHint={item.message.privateMessageHint} commercialAction={item.message.commercialAction} + chatAction={item.message.chatAction} isUnlockingMessage={ isUnlockingMessage === true && item.message.displayId === unlockingMessageId @@ -336,6 +349,9 @@ function renderMessagesWithDateHeaders( onCharacterAvatarClick={onCharacterAvatarClick} onCommercialAction={onCommercialAction} onCommercialActionDismiss={onCommercialActionDismiss} + onChatActionViewed={onChatActionViewed} + onChatAction={onChatAction} + onChatActionDismiss={onChatActionDismiss} /> ), ); diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index bba812f6..c6442773 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -6,6 +6,7 @@ export * from "./ai-disclosure-banner"; export * from "./browser-hint-overlay"; export * from "./chat-area"; export * from "./commercial-action-card"; +export * from "./chat-action-card"; export * from "./chat-header"; export * from "./chat-insufficient-credits-banner"; export * from "./chat-input-bar"; diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 639a1ab3..76d9622c 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -9,7 +9,7 @@ * - 用户:[占位 spacer] [Content] [Avatar] */ import { useUserSelector } from "@/stores/user/user-context"; -import type { CommercialAction } from "@/data/schemas/chat"; +import type { ChatAction, CommercialAction } from "@/data/schemas/chat"; import { MessageAvatar } from "./message-avatar"; import { MessageContent } from "./message-content"; @@ -29,6 +29,7 @@ export interface MessageBubbleProps { lockedPrivate?: boolean | null; privateMessageHint?: string | null; commercialAction?: CommercialAction | null; + chatAction?: ChatAction | null; isUnlockingMessage?: boolean; onUnlockPrivateMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction; @@ -38,6 +39,9 @@ export interface MessageBubbleProps { onCharacterAvatarClick?: () => void; onCommercialAction?: (action: CommercialAction) => void; onCommercialActionDismiss?: (action: CommercialAction) => void; + onChatActionViewed?: (action: ChatAction) => void; + onChatAction?: (action: ChatAction) => void | Promise; + onChatActionDismiss?: (action: ChatAction) => void; } type ChatMessageAction = ( @@ -59,6 +63,7 @@ export function MessageBubble({ lockedPrivate, privateMessageHint, commercialAction, + chatAction, isUnlockingMessage, onUnlockPrivateMessage, onUnlockVoiceMessage, @@ -68,6 +73,9 @@ export function MessageBubble({ onCharacterAvatarClick, onCommercialAction, onCommercialActionDismiss, + onChatActionViewed, + onChatAction, + onChatActionDismiss, }: MessageBubbleProps) { const avatarUrl = useUserSelector((state) => state.context.avatarUrl); @@ -97,6 +105,7 @@ export function MessageBubble({ lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} commercialAction={commercialAction} + chatAction={chatAction} isUnlockingMessage={isUnlockingMessage} onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockVoiceMessage={onUnlockVoiceMessage} @@ -104,6 +113,9 @@ export function MessageBubble({ onOpenImage={onOpenImage} onCommercialAction={onCommercialAction} onCommercialActionDismiss={onCommercialActionDismiss} + onChatActionViewed={onChatActionViewed} + onChatAction={onChatAction} + onChatActionDismiss={onChatActionDismiss} /> diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index 3157dab7..e8e24a4b 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -1,6 +1,7 @@ "use client"; -import type { CommercialAction } from "@/data/schemas/chat"; +import type { ChatAction, CommercialAction } from "@/data/schemas/chat"; +import { ChatActionCard } from "./chat-action-card"; import { CommercialActionCard } from "./commercial-action-card"; import { ImageBubble } from "./image-bubble"; import { LockedImageMessageCard } from "./locked-image-message-card"; @@ -23,6 +24,7 @@ export interface MessageContentProps { lockedPrivate?: boolean | null; privateMessageHint?: string | null; commercialAction?: CommercialAction | null; + chatAction?: ChatAction | null; isUnlockingMessage?: boolean; onUnlockPrivateMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction; @@ -30,6 +32,9 @@ export interface MessageContentProps { onOpenImage?: (displayMessageId: string) => void; onCommercialAction?: (action: CommercialAction) => void; onCommercialActionDismiss?: (action: CommercialAction) => void; + onChatActionViewed?: (action: ChatAction) => void; + onChatAction?: (action: ChatAction) => void | Promise; + onChatActionDismiss?: (action: ChatAction) => void; } type ChatMessageAction = ( @@ -53,6 +58,7 @@ export function MessageContent({ lockedPrivate, privateMessageHint, commercialAction, + chatAction, isUnlockingMessage, onUnlockPrivateMessage, onUnlockVoiceMessage, @@ -60,6 +66,9 @@ export function MessageContent({ onOpenImage, onCommercialAction, onCommercialActionDismiss, + onChatActionViewed, + onChatAction, + onChatActionDismiss, }: MessageContentProps) { const hasImage = imageUrl != null && imageUrl.length > 0; const hasAudio = audioUrl != null && audioUrl.length > 0; @@ -134,7 +143,14 @@ export function MessageContent({ ) : null} )} - {isFromAI && commercialAction ? ( + {isFromAI && chatAction ? ( + + ) : isFromAI && commercialAction ? ( ({ vi.mock("@/stores/user/user-context", () => ({ useUserDispatch: () => vi.fn(), useUserState: () => ({ - currentUser: { - dailyFreeChatLimit: 30, - dailyFreePrivateLimit: 2, + currentUser: null, + entitlements: { + quotas: { + normalChatFreeDaily: 47, + privateUnlockFreeDaily: 5, + }, + costs: { + normal_message: 3, + private_message: 9, + voice_message: 11, + photo: 13, + private_album_10: 101, + private_album_20: 181, + }, }, }), })); @@ -31,5 +42,11 @@ describe("CoinsRulesScreen", () => { expect(html).toContain( 'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"', ); + expect(html).toContain("47 free messages daily"); + expect(html).toContain("5 free private messages daily"); + expect(html).toContain("3 coins / message"); + expect(html).toContain("10 photos / 101 coins"); + expect(html).not.toContain("30 free messages daily"); + expect(html).not.toContain("2 free private messages daily"); }); }); diff --git a/src/app/coins-rules/coins-rules-screen.tsx b/src/app/coins-rules/coins-rules-screen.tsx index 8410dd52..d0a0f2b9 100644 --- a/src/app/coins-rules/coins-rules-screen.tsx +++ b/src/app/coins-rules/coins-rules-screen.tsx @@ -19,10 +19,6 @@ import { import { useAuthState } from "@/stores/auth/auth-context"; import { useUserDispatch, useUserState } from "@/stores/user/user-context"; -import { - FREE_PRIVATE_MESSAGE_DAILY_COUNT, - FREE_STANDARD_CHAT_DAILY_COUNT, -} from "./coins-rules.constants"; import styles from "./coins-rules-screen.module.css"; interface CoinRuleItem { @@ -35,25 +31,44 @@ interface CoinRuleItem { const formatDailyFreeMessages = (count: number, label: string): string => `${Math.max(0, Math.trunc(count))} free ${label} daily`; +const formatCoinCost = (cost: number | null, unit: string): string => + cost === null + ? "Current rate shown at unlock" + : `${Math.max(0, Math.trunc(cost))} coins / ${unit}`; + const createCoinRules = ( - dailyFreeChatLimit: number, - dailyFreePrivateLimit: number, + dailyFreeChatLimit: number | null, + dailyFreePrivateLimit: number | null, + costs: { + normalMessage: number | null; + privateMessage: number | null; + voiceMessage: number | null; + photo: number | null; + privateAlbum10: number | null; + privateAlbum20: number | null; + }, ): readonly CoinRuleItem[] => [ { title: "Standard Chat", - cost: "2 coins / message", - free: formatDailyFreeMessages(dailyFreeChatLimit, "messages"), + cost: formatCoinCost(costs.normalMessage, "message"), + free: + dailyFreeChatLimit === null + ? "Current daily allowance loads from your account" + : formatDailyFreeMessages(dailyFreeChatLimit, "messages"), icon: MessageCircle, }, { title: "Private Chat", - cost: "10 coins / message", - free: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"), + cost: formatCoinCost(costs.privateMessage, "message"), + free: + dailyFreePrivateLimit === null + ? "Current daily allowance loads from your account" + : formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"), icon: Sparkles, }, { title: "Voice Message", - cost: "20 coins / message", + cost: formatCoinCost(costs.voiceMessage, "message"), icon: Mic2, }, // { @@ -63,17 +78,23 @@ const createCoinRules = ( // }, { title: "Photo", - cost: "40 coins / photo", + cost: formatCoinCost(costs.photo, "photo"), icon: ImageIcon, }, { title: "Private Album", - cost: "10 photos / 300 coins", + cost: + costs.privateAlbum10 === null + ? "Current price shown in Private Zone" + : `10 photos / ${Math.max(0, Math.trunc(costs.privateAlbum10))} coins`, icon: ImageIcon, }, { title: "Private Album", - cost: "20 photos / 600 coins", + cost: + costs.privateAlbum20 === null + ? "Current price shown in Private Zone" + : `20 photos / ${Math.max(0, Math.trunc(costs.privateAlbum20))} coins`, icon: ImageIcon, }, ] as const; @@ -87,20 +108,35 @@ export function CoinsRulesScreen({ returnTo }: CoinsRulesScreenProps) { const authState = useAuthState(); const userState = useUserState(); const userDispatch = useUserDispatch(); + const entitlements = userState.entitlements; const dailyFreeChatLimit = - userState.currentUser?.dailyFreeChatLimit ?? FREE_STANDARD_CHAT_DAILY_COUNT; + entitlements?.quotas.normalChatFreeDaily ?? + userState.currentUser?.dailyFreeChatLimit ?? + null; const dailyFreePrivateLimit = + entitlements?.quotas.privateUnlockFreeDaily ?? userState.currentUser?.dailyFreePrivateLimit ?? - FREE_PRIVATE_MESSAGE_DAILY_COUNT; - const standardChatLabel = formatDailyFreeMessages( + null; + const standardChatLabel = + dailyFreeChatLimit === null + ? "the current daily allowance shown for your account" + : formatDailyFreeMessages(dailyFreeChatLimit, "messages"); + const privateChatLabel = + dailyFreePrivateLimit === null + ? "the current private allowance shown for your account" + : formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"); + const coinRules = createCoinRules( dailyFreeChatLimit, - "messages", - ); - const privateChatLabel = formatDailyFreeMessages( dailyFreePrivateLimit, - "private messages", + { + normalMessage: entitlements?.costs.normal_message ?? null, + privateMessage: entitlements?.costs.private_message ?? null, + voiceMessage: entitlements?.costs.voice_message ?? null, + photo: entitlements?.costs.photo ?? null, + privateAlbum10: entitlements?.costs.private_album_10 ?? null, + privateAlbum20: entitlements?.costs.private_album_20 ?? null, + }, ); - const coinRules = createCoinRules(dailyFreeChatLimit, dailyFreePrivateLimit); useEffect(() => { if (!authState.hasInitialized || authState.isLoading) return; diff --git a/src/app/coins-rules/coins-rules.constants.ts b/src/app/coins-rules/coins-rules.constants.ts deleted file mode 100644 index 708719b6..00000000 --- a/src/app/coins-rules/coins-rules.constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const FREE_STANDARD_CHAT_DAILY_COUNT = 30; -export const FREE_PRIVATE_MESSAGE_DAILY_COUNT = 2; diff --git a/src/app/subscription/page.tsx b/src/app/subscription/page.tsx index ea68131e..b695bbfb 100644 --- a/src/app/subscription/page.tsx +++ b/src/app/subscription/page.tsx @@ -36,6 +36,8 @@ export default async function SubscriptionPage({ const commercialOfferId = getFirstPaymentSearchParam( query.commercialOfferId, ); + const resumeOrderId = getFirstPaymentSearchParam(query.resumeOrderId); + const chatActionId = getFirstPaymentSearchParam(query.chatActionId); const analyticsContext = parsePaymentAnalyticsContext({ entryPoint: getFirstPaymentSearchParam( query[PAYMENT_ANALYTICS_ENTRY_PARAM], @@ -56,6 +58,8 @@ export default async function SubscriptionPage({ sourceCharacterSlug={sourceCharacterSlug} initialPlanId={initialPlanId} commercialOfferId={commercialOfferId} + resumeOrderId={resumeOrderId} + chatActionId={chatActionId} /> ); } diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 47f8598d..12c4f4f0 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -47,6 +47,8 @@ export interface SubscriptionScreenProps { sourceCharacterSlug?: string; initialPlanId?: string | null; commercialOfferId?: string | null; + resumeOrderId?: string | null; + chatActionId?: string | null; } export function SubscriptionScreen({ @@ -58,6 +60,8 @@ export function SubscriptionScreen({ sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, initialPlanId = null, commercialOfferId = null, + resumeOrderId = null, + chatActionId = null, }: SubscriptionScreenProps) { const userState = useUserState(); const hasHydrated = useHasHydrated(); @@ -83,6 +87,8 @@ export function SubscriptionScreen({ initialPayChannel: paymentMethodConfig.initialPayChannel, initialPlanId, commercialOfferId, + resumeOrderId, + chatActionId, }); const canSubscribeVip = subscriptionType === "vip"; const analyticsContext = diff --git a/src/app/subscription/use-subscription-payment-flow.ts b/src/app/subscription/use-subscription-payment-flow.ts index 96fc9106..579354fd 100644 --- a/src/app/subscription/use-subscription-payment-flow.ts +++ b/src/app/subscription/use-subscription-payment-flow.ts @@ -22,6 +22,8 @@ export interface UseSubscriptionPaymentFlowInput { sourceCharacterSlug?: string; initialPlanId?: string | null; commercialOfferId?: string | null; + resumeOrderId?: string | null; + chatActionId?: string | null; } export function useSubscriptionPaymentFlow({ @@ -32,6 +34,8 @@ export function useSubscriptionPaymentFlow({ sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, initialPlanId = null, commercialOfferId = null, + resumeOrderId = null, + chatActionId = null, }: UseSubscriptionPaymentFlowInput) { const router = useRouter(); const { payment, paymentDispatch } = usePaymentRouteFlow({ @@ -40,6 +44,8 @@ export function useSubscriptionPaymentFlow({ shouldResumePendingOrder, initialPlanId, commercialOfferId, + resumeOrderId, + chatActionId, }); const successDialogShownOrderRef = useRef(null); const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index 478e1575..ebf44d01 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -40,6 +40,7 @@ export interface TipScreenProps { initialPlanId?: string | null; shouldResumePendingOrder?: boolean; initialPayChannel?: PayChannel | null; + chatActionId?: string | null; } export function TipScreen({ @@ -47,6 +48,7 @@ export function TipScreen({ initialPlanId = null, shouldResumePendingOrder = false, initialPayChannel = null, + chatActionId = null, }: TipScreenProps) { const character = useActiveCharacter(); const characterRoutes = useActiveCharacterRoutes(); @@ -68,6 +70,7 @@ export function TipScreen({ initialPayChannel: paymentMethodConfig.initialPayChannel, paymentType: "tip", shouldResumePendingOrder, + chatActionId, }); const selectedCategory = diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 2303f0ee..80231ef4 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -14,6 +14,8 @@ import { AppEnvUtil } from "@/utils/app-env"; import { Logger } from "@/utils/logger"; import { CommercialActionSchema, + ChatActionSchema, + type ChatAction, type CommercialAction, } from "@/data/schemas/chat"; @@ -45,6 +47,7 @@ export class ChatWebSocket { onImage: ((url: string) => void) | null = null; onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null; onCommercialAction: ((action: CommercialAction) => void) | null = null; + onChatAction: ((action: ChatAction) => void) | null = null; onError: ((errorMessage: string) => void) | null = null; constructor( @@ -151,6 +154,8 @@ export class ChatWebSocket { ctaLabel?: string; target?: string; ruleId?: string; + kind?: string; + orderId?: string | null; }; }; try { @@ -195,6 +200,11 @@ export class ChatWebSocket { if (action.success) this.onCommercialAction?.(action.data); break; } + case "chat_action": { + const action = ChatActionSchema.safeParse(payload.data); + if (action.success) this.onChatAction?.(action.data); + break; + } case "error": this.onError?.(payload.error ?? "Unknown error"); break; diff --git a/src/data/repositories/__tests__/payment_repository.test.ts b/src/data/repositories/__tests__/payment_repository.test.ts index b50d175d..84632339 100644 --- a/src/data/repositories/__tests__/payment_repository.test.ts +++ b/src/data/repositories/__tests__/payment_repository.test.ts @@ -9,6 +9,33 @@ import type { PaymentApi } from "@/data/services/api"; import { Result } from "@/utils/result"; describe("PaymentRepository", () => { + it("forwards chat action attribution only in the owned order request", async () => { + const createOrder = vi.fn().mockResolvedValue({ + orderId: "pay_xxx", + payParams: null, + }); + const repository = new PaymentRepository({ + createOrder, + } as unknown as PaymentApi); + + const result = await repository.createOrder( + "vip_monthly", + "stripe", + true, + undefined, + undefined, + "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + ); + + expect(createOrder).toHaveBeenCalledWith({ + planId: "vip_monthly", + payChannel: "stripe", + autoRenew: true, + chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + }); + expect(Result.isOk(result)).toBe(true); + }); + it("loads a character gift catalog without adapting product metadata", async () => { const catalog = GiftProductsResponseSchema.parse({ characterId: "elio", diff --git a/src/data/repositories/interfaces/ipayment_repository.ts b/src/data/repositories/interfaces/ipayment_repository.ts index 7f1be4c2..f54953ba 100644 --- a/src/data/repositories/interfaces/ipayment_repository.ts +++ b/src/data/repositories/interfaces/ipayment_repository.ts @@ -31,6 +31,7 @@ export interface IPaymentRepository { autoRenew: boolean, recipientCharacterId?: string, commercialOfferId?: string, + chatActionId?: string, ): Promise>; /** 查询支付订单状态。 */ diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts index 7c2daf55..d1fe3415 100644 --- a/src/data/repositories/payment_repository.ts +++ b/src/data/repositories/payment_repository.ts @@ -63,6 +63,7 @@ export class PaymentRepository implements IPaymentRepository { autoRenew: boolean, recipientCharacterId?: string, commercialOfferId?: string, + chatActionId?: string, ): Promise> { const request = CreatePaymentOrderRequestSchema.parse({ planId, @@ -70,6 +71,7 @@ export class PaymentRepository implements IPaymentRepository { autoRenew, ...(recipientCharacterId ? { recipientCharacterId } : {}), ...(commercialOfferId ? { commercialOfferId } : {}), + ...(chatActionId ? { chatActionId } : {}), }); return Result.wrap(() => this.api.createOrder(request)); } diff --git a/src/data/schemas/chat/__tests__/chat_action.test.ts b/src/data/schemas/chat/__tests__/chat_action.test.ts new file mode 100644 index 00000000..1e027f91 --- /dev/null +++ b/src/data/schemas/chat/__tests__/chat_action.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { + ChatActionEventRequestSchema, + ChatActionSchema, +} from "@/data/schemas/chat"; + +describe("ChatActionSchema", () => { + it("accepts a support action without an order", () => { + expect( + ChatActionSchema.parse({ + actionId: "action-1", + kind: "support", + type: "openFeedback", + copy: "Send the screenshot and approximate payment time here.", + ctaLabel: "Open Feedback", + ruleId: null, + orderId: null, + }), + ).toMatchObject({ type: "openFeedback", orderId: null }); + }); + + it("requires orderId only for resumePayment", () => { + expect(() => + ChatActionSchema.parse({ + actionId: "action-2", + kind: "support", + type: "resumePayment", + copy: "You can continue the pending order here.", + ctaLabel: "Continue", + ruleId: null, + orderId: null, + }), + ).toThrow(); + expect(() => + ChatActionSchema.parse({ + actionId: "action-3", + kind: "support", + type: "openWallet", + copy: "Open your wallet.", + ctaLabel: "Open Wallet", + ruleId: null, + orderId: "order-not-allowed", + }), + ).toThrow(); + }); +}); + +describe("ChatActionEventRequestSchema", () => { + it("requires a UUID event id and an allowlisted event type", () => { + expect( + ChatActionEventRequestSchema.parse({ + eventId: "4b223884-02ec-4fd2-aacf-e84ee8ca3adb", + actionId: "action-1", + characterId: "elio", + eventType: "arrived", + }), + ).toMatchObject({ eventType: "arrived" }); + expect(() => + ChatActionEventRequestSchema.parse({ + eventId: "not-a-uuid", + actionId: "action-1", + characterId: "elio", + eventType: "clicked", + }), + ).toThrow(); + }); +}); diff --git a/src/data/schemas/chat/chat_action.ts b/src/data/schemas/chat/chat_action.ts new file mode 100644 index 00000000..9ac69fee --- /dev/null +++ b/src/data/schemas/chat/chat_action.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +export const ChatActionTypeSchema = z.enum([ + "giftOffer", + "privateAlbumOffer", + "openProfile", + "openWallet", + "openCoinsRules", + "activateVip", + "topUp", + "openFeedback", + "resumePayment", +]); + +export const ChatActionSchema = z + .object({ + actionId: z.string().min(1), + kind: z.enum(["support", "commercial"]), + type: ChatActionTypeSchema, + copy: z.string().min(1), + ctaLabel: z.string().min(1), + ruleId: z.string().min(1).nullable(), + orderId: z.string().min(1).nullable(), + }) + .superRefine((action, context) => { + if (action.type === "resumePayment" && action.orderId === null) { + context.addIssue({ + code: "custom", + path: ["orderId"], + message: "orderId is required for resumePayment", + }); + } + if (action.type !== "resumePayment" && action.orderId !== null) { + context.addIssue({ + code: "custom", + path: ["orderId"], + message: "orderId is only allowed for resumePayment", + }); + } + }) + .readonly(); + +export const ChatActionEventTypeSchema = z.enum([ + "viewed", + "opened", + "dismissed", + "arrived", +]); + +export const ChatActionEventRequestSchema = z + .object({ + eventId: z.uuid(), + actionId: z.string().min(1), + characterId: z.string().min(1), + eventType: ChatActionEventTypeSchema, + }) + .readonly(); + +export const ChatActionEventResponseSchema = z + .object({ + eventId: z.uuid(), + actionId: z.string().min(1), + eventType: ChatActionEventTypeSchema, + duplicate: z.boolean(), + }) + .readonly(); + +export type ChatAction = z.output; +export type ChatActionEventType = z.output; +export type ChatActionEventRequest = z.input< + typeof ChatActionEventRequestSchema +>; +export type ChatActionEventResponse = z.output< + typeof ChatActionEventResponseSchema +>; diff --git a/src/data/schemas/chat/index.ts b/src/data/schemas/chat/index.ts index e4bfa481..e272b5c1 100644 --- a/src/data/schemas/chat/index.ts +++ b/src/data/schemas/chat/index.ts @@ -7,6 +7,7 @@ export * from "./chat_media"; export * from "./chat_message"; export * from "./opening_message"; export * from "./chat_payloads"; +export * from "./chat_action"; export * from "./request/send_message_request"; export * from "./request/unlock_history_request"; export * from "./request/unlock_private_request"; diff --git a/src/data/schemas/chat/response/chat_send_response.ts b/src/data/schemas/chat/response/chat_send_response.ts index 48e1b3c6..304571ad 100644 --- a/src/data/schemas/chat/response/chat_send_response.ts +++ b/src/data/schemas/chat/response/chat_send_response.ts @@ -11,6 +11,7 @@ import { stringOrEmpty, } from "../../nullable-defaults"; import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads"; +import { ChatActionSchema } from "../chat_action"; export const CommercialActionSchema = z .object({ @@ -41,6 +42,7 @@ export const ChatSendResponseSchema = z requiredCredits: numberOrZero, shortfallCredits: numberOrZero, commercialAction: CommercialActionSchema.nullish().default(null), + chatAction: ChatActionSchema.nullish().default(null), }) .readonly(); diff --git a/src/data/schemas/payment/__tests__/create_payment_order_request.test.ts b/src/data/schemas/payment/__tests__/create_payment_order_request.test.ts new file mode 100644 index 00000000..32940268 --- /dev/null +++ b/src/data/schemas/payment/__tests__/create_payment_order_request.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { CreatePaymentOrderRequestSchema } from "@/data/schemas/payment"; + +describe("CreatePaymentOrderRequestSchema", () => { + const baseRequest = { + planId: "vip_monthly", + payChannel: "stripe", + autoRenew: true, + } as const; + + it("accepts a UUID chatActionId for funnel attribution", () => { + expect( + CreatePaymentOrderRequestSchema.parse({ + ...baseRequest, + chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + }), + ).toMatchObject({ + ...baseRequest, + chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + }); + }); + + it("rejects a non-UUID chatActionId", () => { + expect(() => + CreatePaymentOrderRequestSchema.parse({ + ...baseRequest, + chatActionId: "action-1", + }), + ).toThrow(); + }); +}); diff --git a/src/data/schemas/payment/request/create_payment_order_request.ts b/src/data/schemas/payment/request/create_payment_order_request.ts index 0b8b1b32..3b8dcb8e 100644 --- a/src/data/schemas/payment/request/create_payment_order_request.ts +++ b/src/data/schemas/payment/request/create_payment_order_request.ts @@ -12,6 +12,7 @@ export const CreatePaymentOrderRequestSchema = z autoRenew: z.boolean(), recipientCharacterId: z.string().min(1).optional(), commercialOfferId: z.string().min(1).optional(), + chatActionId: z.uuid().optional(), }) .readonly(); diff --git a/src/data/services/api/__tests__/multi_character_api.test.ts b/src/data/services/api/__tests__/multi_character_api.test.ts index 27a5f2a3..3fbdc7c1 100644 --- a/src/data/services/api/__tests__/multi_character_api.test.ts +++ b/src/data/services/api/__tests__/multi_character_api.test.ts @@ -60,6 +60,26 @@ describe("multi-character API contract", () => { }); }); + it("reports an idempotent chat action event", async () => { + const body = { + eventId: "4b223884-02ec-4fd2-aacf-e84ee8ca3adb", + actionId: "action-1", + characterId: CHARACTER_ID, + eventType: "opened" as const, + }; + httpClientMock.mockResolvedValue({ + success: true, + data: { ...body, duplicate: false }, + }); + + await new ChatApi().recordActionEvent(body); + + expect(httpClientMock).toHaveBeenCalledWith("/api/chat/action-events", { + method: "POST", + body, + }); + }); + it("persists the character opening message with the canonical contract", async () => { httpClientMock.mockResolvedValue({ success: true, diff --git a/src/data/services/api/api_contract.json b/src/data/services/api/api_contract.json index 1753b79d..b372f686 100644 --- a/src/data/services/api/api_contract.json +++ b/src/data/services/api/api_contract.json @@ -19,6 +19,7 @@ "paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" }, "paymentCommercialOfferAccept": { "method": "post", "path": "/api/payment/commercial-offers/{offerId}/accept" }, "chatSend": { "method": "post", "path": "/api/chat/send" }, + "chatActionEvents": { "method": "post", "path": "/api/chat/action-events" }, "chatOpeningMessage": { "method": "post", "path": "/api/chat/opening-message" }, "chatHistory": { "method": "get", "path": "/api/chat/history" }, "chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" }, diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index 87b14aa2..bd7c2e33 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -78,6 +78,9 @@ export class ApiPath { /** 发送消息 */ static readonly chatSend = apiContract.chatSend.path; + /** 记录角色聊天操作卡漏斗事件 */ + static readonly chatActionEvents = apiContract.chatActionEvents.path; + /** 幂等保存角色开场白 */ static readonly chatOpeningMessage = apiContract.chatOpeningMessage.path; diff --git a/src/data/services/api/chat_api.ts b/src/data/services/api/chat_api.ts index a5e37052..ed8f84dd 100644 --- a/src/data/services/api/chat_api.ts +++ b/src/data/services/api/chat_api.ts @@ -11,6 +11,10 @@ import { ChatPreviewsResponseSchema, ChatSendResponse, ChatSendResponseSchema, + ChatActionEventRequest, + ChatActionEventRequestSchema, + ChatActionEventResponse, + ChatActionEventResponseSchema, OpeningMessageRequest, OpeningMessageResponse, OpeningMessageResponseSchema, @@ -42,6 +46,21 @@ export class ChatApi { return ChatSendResponseSchema.parse(unwrap(env) as Record); } + /** 上报白名单聊天操作卡事件;eventId 用于安全重试。 */ + async recordActionEvent( + body: ChatActionEventRequest, + ): Promise { + const request = ChatActionEventRequestSchema.parse(body); + const env = await httpClient>( + ApiPath.chatActionEvents, + { + method: "POST", + body: request, + }, + ); + return ChatActionEventResponseSchema.parse(unwrap(env)); + } + /** 幂等保存当前角色的首次开场白。 */ async saveOpeningMessage( body: OpeningMessageRequest, diff --git a/src/lib/chat/__tests__/chat_action_events.test.tsx b/src/lib/chat/__tests__/chat_action_events.test.tsx new file mode 100644 index 00000000..3609a1a0 --- /dev/null +++ b/src/lib/chat/__tests__/chat_action_events.test.tsx @@ -0,0 +1,62 @@ +/* @vitest-environment jsdom */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const recordActionEventMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/data/services/api", () => ({ + chatApi: { recordActionEvent: recordActionEventMock }, +})); + +import { + readPendingChatActionArrival, + recordChatActionEvent, + rememberChatActionArrival, +} from "../chat_action_events"; + +const action = { + actionId: "action-1", + kind: "support" as const, + type: "openFeedback" as const, + copy: "Open feedback.", + ctaLabel: "Open Feedback", + ruleId: null, + orderId: null, +}; + +describe("chat action events", () => { + beforeEach(() => { + window.sessionStorage.clear(); + recordActionEventMock.mockReset(); + recordActionEventMock.mockResolvedValue({}); + }); + + it("reuses the event UUID when an event is safely retried", async () => { + await recordChatActionEvent(action, "elio", "opened"); + await recordChatActionEvent(action, "elio", "opened"); + + const first = recordActionEventMock.mock.calls[0]?.[0]; + const second = recordActionEventMock.mock.calls[1]?.[0]; + expect(first.eventId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(second.eventId).toBe(first.eventId); + }); + + it("tracks the intended destination instead of an external URL", () => { + rememberChatActionArrival( + action, + "elio", + "/feedback?returnTo=%2Fcharacters%2Felio%2Fchat", + ); + expect(readPendingChatActionArrival()).toEqual({ + actionId: "action-1", + characterId: "elio", + expectedPath: "/feedback", + }); + + window.sessionStorage.clear(); + rememberChatActionArrival(action, "elio", "https://example.com/checkout"); + expect(readPendingChatActionArrival()).toBeNull(); + }); +}); diff --git a/src/lib/chat/chat_action_events.ts b/src/lib/chat/chat_action_events.ts new file mode 100644 index 00000000..92894225 --- /dev/null +++ b/src/lib/chat/chat_action_events.ts @@ -0,0 +1,100 @@ +"use client"; + +import type { + ChatAction, + ChatActionEventType, +} from "@/data/schemas/chat"; +import { chatApi } from "@/data/services/api"; + +const EVENT_PREFIX = "cozsweet:chat-action-event:v1"; +const ARRIVAL_KEY = "cozsweet:chat-action-arrival:v1"; + +interface PendingChatActionArrival { + actionId: string; + characterId: string; + expectedPath: string; +} + +export async function recordChatActionEvent( + action: ChatAction, + characterId: string, + eventType: ChatActionEventType, +): Promise { + await recordChatActionEventById(action.actionId, characterId, eventType); +} + +export async function recordChatActionEventById( + actionId: string, + characterId: string, + eventType: ChatActionEventType, +): Promise { + await chatApi.recordActionEvent({ + eventId: getOrCreateEventId(actionId, eventType), + actionId, + characterId, + eventType, + }); +} + +export function rememberChatActionArrival( + action: ChatAction, + characterId: string, + targetUrl: string, +): void { + if (typeof window === "undefined") return; + const target = new URL(targetUrl, window.location.origin); + if (target.origin !== window.location.origin) return; + const pending: PendingChatActionArrival = { + actionId: action.actionId, + characterId, + expectedPath: target.pathname, + }; + window.sessionStorage.setItem(ARRIVAL_KEY, JSON.stringify(pending)); +} + +export function readPendingChatActionArrival(): PendingChatActionArrival | null { + if (typeof window === "undefined") return null; + try { + const raw = window.sessionStorage.getItem(ARRIVAL_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (!parsed.actionId || !parsed.characterId || !parsed.expectedPath) { + return null; + } + return parsed as PendingChatActionArrival; + } catch { + return null; + } +} + +export function clearPendingChatActionArrival(): void { + if (typeof window === "undefined") return; + window.sessionStorage.removeItem(ARRIVAL_KEY); +} + +function getOrCreateEventId( + actionId: string, + eventType: ChatActionEventType, +): string { + const key = `${EVENT_PREFIX}:${actionId}:${eventType}`; + if (typeof window !== "undefined") { + const existing = window.sessionStorage.getItem(key); + if (existing) return existing; + } + const eventId = createUuid(); + if (typeof window !== "undefined") { + window.sessionStorage.setItem(key, eventId); + } + return eventId; +} + +function createUuid(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (value) => { + const random = Math.floor(Math.random() * 16); + const next = value === "x" ? random : (random & 0x3) | 0x8; + return next.toString(16); + }); +} diff --git a/src/providers/chat-action-arrival-tracker.tsx b/src/providers/chat-action-arrival-tracker.tsx new file mode 100644 index 00000000..74aed7bb --- /dev/null +++ b/src/providers/chat-action-arrival-tracker.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; + +import { + clearPendingChatActionArrival, + readPendingChatActionArrival, + recordChatActionEventById, +} from "@/lib/chat/chat_action_events"; + +export function ChatActionArrivalTracker() { + const pathname = usePathname(); + + useEffect(() => { + const pending = readPendingChatActionArrival(); + if (!pending || pathname !== pending.expectedPath) return; + void recordChatActionEventById( + pending.actionId, + pending.characterId, + "arrived", + ) + .then(() => clearPendingChatActionArrival()) + .catch(() => undefined); + }, [pathname]); + + return null; +} diff --git a/src/providers/root-providers.tsx b/src/providers/root-providers.tsx index d77b8cb8..9996fe12 100644 --- a/src/providers/root-providers.tsx +++ b/src/providers/root-providers.tsx @@ -10,6 +10,7 @@ import type { ReactNode } from "react"; import { CharacterCatalogProvider } from "@/providers/character-catalog-provider"; +import { ChatActionArrivalTracker } from "@/providers/chat-action-arrival-tracker"; import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider"; import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider"; import { AppNavigationGuard } from "@/router/app-navigation-guard"; @@ -31,6 +32,7 @@ export function RootProviders({ children }: RootProvidersProps) { + diff --git a/src/router/routes.ts b/src/router/routes.ts index 6cca83c6..48e81324 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -87,6 +87,8 @@ export const ROUTE_BUILDERS = { analytics?: PaymentAnalyticsContext; planId?: string; commercialOfferId?: string; + resumeOrderId?: string; + chatActionId?: string; } = {}, ): `/subscription?${string}` => { const params = new URLSearchParams({ type }); @@ -99,6 +101,12 @@ export const ROUTE_BUILDERS = { if (options.commercialOfferId) { params.set("commercialOfferId", options.commercialOfferId); } + if (options.resumeOrderId) { + params.set("resumeOrderId", options.resumeOrderId); + } + if (options.chatActionId) { + params.set("chatActionId", options.chatActionId); + } if (options.analytics) { params.set( PAYMENT_ANALYTICS_ENTRY_PARAM, diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index 4a0c9efe..10b908cc 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -96,6 +96,33 @@ describe("sendResponseToUiMessage", () => { }); }); + it("prefers the new chat action over the legacy commercial action", () => { + const message = sendResponseToUiMessage( + makeResponse({ + chatAction: { + actionId: "chat-action-1", + kind: "support", + type: "openFeedback", + copy: "Share the screenshot here.", + ctaLabel: "Open Feedback", + ruleId: null, + orderId: null, + }, + commercialAction: { + actionId: "legacy-action-1", + type: "giftOffer", + copy: "View gifts.", + ctaLabel: "View gifts", + target: "giftCatalog", + ruleId: "legacy-rule", + }, + }), + ); + + expect(message.chatAction?.actionId).toBe("chat-action-1"); + expect(message.commercialAction).toBeUndefined(); + }); + it("maps locked voice messages without treating them as private text", () => { const message = sendResponseToUiMessage( makeResponse({ diff --git a/src/stores/chat/helper/message-mappers.ts b/src/stores/chat/helper/message-mappers.ts index 53772027..bfa59034 100644 --- a/src/stores/chat/helper/message-mappers.ts +++ b/src/stores/chat/helper/message-mappers.ts @@ -74,9 +74,11 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { ...(response.audioUrl && !response.lockDetail.locked ? { audioUrl: response.audioUrl } : {}), - ...(response.commercialAction - ? { commercialAction: response.commercialAction } - : {}), + ...(response.chatAction + ? { chatAction: response.chatAction } + : response.commercialAction + ? { commercialAction: response.commercialAction } + : {}), ...deriveUiLockFields(response.lockDetail, response.image.url), }; } diff --git a/src/stores/chat/ui-message.ts b/src/stores/chat/ui-message.ts index cf34da80..4dfd0175 100644 --- a/src/stores/chat/ui-message.ts +++ b/src/stores/chat/ui-message.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { CommercialActionSchema } from "@/data/schemas/chat"; +import { ChatActionSchema, CommercialActionSchema } from "@/data/schemas/chat"; export const UiMessageSchema = z.object({ displayId: z.string().min(1), @@ -18,6 +18,7 @@ export const UiMessageSchema = z.object({ lockedPrivate: z.boolean().nullable().optional(), privateMessageHint: z.string().nullable().optional(), commercialAction: CommercialActionSchema.nullable().optional(), + chatAction: ChatActionSchema.nullable().optional(), }); export type UiMessage = z.infer; diff --git a/src/stores/payment/__tests__/payment-machine.test-utils.ts b/src/stores/payment/__tests__/payment-machine.test-utils.ts index 113f8ad8..2c6f8481 100644 --- a/src/stores/payment/__tests__/payment-machine.test-utils.ts +++ b/src/stores/payment/__tests__/payment-machine.test-utils.ts @@ -28,6 +28,7 @@ export interface CreateOrderInput { autoRenew: boolean; recipientCharacterId?: string; commercialOfferId?: string; + chatActionId?: string; } export type CreateOrderSpy = (input: CreateOrderInput) => void; diff --git a/src/stores/payment/__tests__/payment-order-flow.test.ts b/src/stores/payment/__tests__/payment-order-flow.test.ts index 7063c18d..c83e0e6e 100644 --- a/src/stores/payment/__tests__/payment-order-flow.test.ts +++ b/src/stores/payment/__tests__/payment-order-flow.test.ts @@ -105,6 +105,30 @@ describe("payment order flow", () => { actor.stop(); }); + it("carries the originating chat action into order creation", async () => { + const createOrderSpy = vi.fn(); + const actor = createActor( + createTestPaymentMachine({ createOrderSpy }), + ).start(); + actor.send({ + type: "PaymentInit", + planId: "vip_monthly", + chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + actor.send({ type: "PaymentCreateOrderSubmitted" }); + await waitFor(actor, (snapshot) => snapshot.matches("paid")); + + expect(createOrderSpy).toHaveBeenCalledWith({ + planId: "vip_monthly", + payChannel: "stripe", + autoRenew: true, + chatActionId: "019c8f8d-17d3-7a42-b1cc-b6927b1927d5", + }); + actor.stop(); + }); + it("loads a stable Tip message after payment and clears it on reset", async () => { const loadTipMessage = vi.fn(); const actor = createActor( diff --git a/src/stores/payment/machine/actors/order.ts b/src/stores/payment/machine/actors/order.ts index ecfc6450..9adcdc97 100644 --- a/src/stores/payment/machine/actors/order.ts +++ b/src/stores/payment/machine/actors/order.ts @@ -16,6 +16,7 @@ export const createPaymentOrderActor = fromPromise< autoRenew: boolean; recipientCharacterId?: string; commercialOfferId?: string; + chatActionId?: string; } >(async ({ input }) => { const result = await getPaymentRepository().createOrder( @@ -24,6 +25,7 @@ export const createPaymentOrderActor = fromPromise< input.autoRenew, input.recipientCharacterId, input.commercialOfferId, + input.chatActionId, ); if (Result.isErr(result)) throw result.error; return result.data; diff --git a/src/stores/payment/machine/catalog-flow.ts b/src/stores/payment/machine/catalog-flow.ts index 168218cf..0cd62b0d 100644 --- a/src/stores/payment/machine/catalog-flow.ts +++ b/src/stores/payment/machine/catalog-flow.ts @@ -38,17 +38,20 @@ function initializeCatalogState( const commercialOfferId = planCatalog === "default" ? (event.commercialOfferId ?? null) : null; const offerChanged = commercialOfferId !== context.commercialOfferId; + const chatActionId = event.chatActionId ?? null; + const chatActionChanged = chatActionId !== context.chatActionId; return { planCatalog, ...(event.payChannel ? { payChannel: event.payChannel } : {}), giftCharacterId, commercialOfferId, + chatActionId, requestedGiftCategory: planCatalog === "tip" ? (event.category ?? null) : null, requestedGiftPlanId: planCatalog === "tip" ? (event.planId ?? null) : null, - ...(catalogChanged || characterChanged || selectionChanged || offerChanged + ...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged ? { ...resetOrderState(), plans: [], diff --git a/src/stores/payment/machine/order-flow.ts b/src/stores/payment/machine/order-flow.ts index aaedf8d8..891f9dad 100644 --- a/src/stores/payment/machine/order-flow.ts +++ b/src/stores/payment/machine/order-flow.ts @@ -198,6 +198,9 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({ ...(context.commercialOfferId ? { commercialOfferId: context.commercialOfferId } : {}), + ...(context.chatActionId + ? { chatActionId: context.chatActionId } + : {}), }), onDone: { target: "pollingOrder", diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index f2d30df4..3d75b52f 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -11,6 +11,7 @@ export type PaymentEvent = category?: string | null; planId?: string | null; commercialOfferId?: string | null; + chatActionId?: string | null; } | { type: "PaymentPlanSelected"; planId: string } | { type: "PaymentPayChannelChanged"; payChannel: PayChannel } diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts index eec27e06..6fcb3bed 100644 --- a/src/stores/payment/payment-state.ts +++ b/src/stores/payment/payment-state.ts @@ -22,6 +22,7 @@ export interface PaymentState { requestedGiftPlanId: string | null; isFirstRecharge: boolean; commercialOfferId: string | null; + chatActionId: string | null; commercialOffer: CommercialOfferSummary | null; selectedPlanId: string; payChannel: PayChannel; @@ -49,6 +50,7 @@ export const initialState: PaymentState = { requestedGiftPlanId: null, isFirstRecharge: false, commercialOfferId: null, + chatActionId: null, commercialOffer: null, selectedPlanId: "", payChannel: "stripe", diff --git a/src/stores/user/machine/actors/profile.ts b/src/stores/user/machine/actors/profile.ts index 2691234c..262aa1ba 100644 --- a/src/stores/user/machine/actors/profile.ts +++ b/src/stores/user/machine/actors/profile.ts @@ -1,7 +1,10 @@ import { fromPromise } from "xstate"; import { getUserRepository } from "@/data/repositories/user_repository"; -import type { UserEntitlementSnapshotData } from "@/data/schemas/user"; +import type { + UserEntitlementsData, + UserEntitlementSnapshotData, +} from "@/data/schemas/user"; import { UserStorage } from "@/data/storage/user/user_storage"; import type { UserView } from "@/stores/user/user-view"; import { Result } from "@/utils/result"; @@ -15,6 +18,7 @@ import { export interface UserFetchData { user: UserView | null; snapshot: UserEntitlementSnapshotData | null; + entitlements?: UserEntitlementsData | null; } export const userFetchActor = fromPromise(async () => { @@ -31,14 +35,17 @@ export const userFetchActor = fromPromise(async () => { creditBalance: entitlementsResult.data.creditBalance, }) : null; + const entitlements = Result.isOk(entitlementsResult) + ? entitlementsResult.data + : null; if (snapshot) await userStorage.setEntitlementSnapshot(snapshot); - if (Result.isErr(userResult)) return { user: null, snapshot }; + if (Result.isErr(userResult)) return { user: null, snapshot, entitlements }; const view = applyEntitlementSnapshotToView( toView(userResult.data), snapshot, ); await userStorage.setUser(userResult.data); - return { user: view, snapshot }; + return { user: view, snapshot, entitlements }; }); diff --git a/src/stores/user/machine/profile-flow.ts b/src/stores/user/machine/profile-flow.ts index 6cef2972..7496b81a 100644 --- a/src/stores/user/machine/profile-flow.ts +++ b/src/stores/user/machine/profile-flow.ts @@ -49,6 +49,7 @@ const applyFetchedUserAction = userFetchDoneSetup.assign( context.currentUser, isVip: snapshot?.isVip ?? context.isVip, creditBalance: snapshot?.creditBalance ?? context.creditBalance, + entitlements: event.output.entitlements ?? context.entitlements, isLoading: false, }; }, diff --git a/src/stores/user/machine/session-flow.ts b/src/stores/user/machine/session-flow.ts index be0281d8..b5c25038 100644 --- a/src/stores/user/machine/session-flow.ts +++ b/src/stores/user/machine/session-flow.ts @@ -10,6 +10,7 @@ const clearUserAction = profileMachineSetup.assign(() => ({ avatarUrl: null, isVip: false, creditBalance: 0, + entitlements: null, })); export const userMachineSetup = profileMachineSetup.extend({ diff --git a/src/stores/user/user-context.tsx b/src/stores/user/user-context.tsx index 74aa9053..2f137f4e 100644 --- a/src/stores/user/user-context.tsx +++ b/src/stores/user/user-context.tsx @@ -18,6 +18,7 @@ interface UserState { avatarUrl: string | null; isVip: boolean; creditBalance: number; + entitlements: MachineContext["entitlements"]; } type UserSnapshot = SnapshotFrom; @@ -59,5 +60,6 @@ function selectUserState(state: UserSnapshot): UserState { avatarUrl: state.context.avatarUrl, isVip: state.context.isVip, creditBalance: state.context.creditBalance, + entitlements: state.context.entitlements, }; } diff --git a/src/stores/user/user-state.ts b/src/stores/user/user-state.ts index e9a0b5e5..1623858d 100644 --- a/src/stores/user/user-state.ts +++ b/src/stores/user/user-state.ts @@ -2,6 +2,7 @@ * User 状态机:State 形状 + 初始值 */ import type { UserView } from "@/stores/user/user-view"; +import type { UserEntitlementsData } from "@/data/schemas/user"; export interface UserState { currentUser: UserView | null; @@ -9,6 +10,7 @@ export interface UserState { avatarUrl: string | null; isVip: boolean; creditBalance: number; + entitlements: UserEntitlementsData | null; } export const initialState: UserState = { @@ -17,4 +19,5 @@ export const initialState: UserState = { avatarUrl: null, isVip: false, creditBalance: 0, + entitlements: null, };