feat(chat): add support action cards and live entitlements
Docker Image / Build and Push Docker Image (push) Successful in 2m2s

This commit is contained in:
Codex
2026-07-24 17:59:57 +08:00
parent 17236bd14e
commit 30ab2c2c97
52 changed files with 1151 additions and 40 deletions
+14
View File
@@ -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<string | null>(null);
const resumedOrderIdRef = useRef<string | null>(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 };
}
@@ -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 (
<TipScreen
@@ -30,6 +31,7 @@ export default async function CharacterTipPage({
initialPlanId={initialPlanId}
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
initialPayChannel={paymentReturn.initialPayChannel}
chatActionId={chatActionId}
/>
);
}
@@ -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<PaymentOrderStatusResponse> = {},
): 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");
});
});
+64
View File
@@ -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<PaymentOrderStatusResponse>;
}
/** Resolve only application-owned, allowlisted destinations for a chat action. */
export async function resolveChatActionTarget(
action: ChatAction,
context: ChatActionNavigationContext,
): Promise<string> {
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,
},
);
}
}
}
+80 -1
View File
@@ -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<void> {
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 (
<MobileShell>
<div
@@ -375,6 +451,9 @@ export function ChatScreen() {
onCharacterAvatarClick={handleOpenCharacterPrivateZone}
onCommercialAction={handleCommercialAction}
onCommercialActionDismiss={handleCommercialActionDismiss}
onChatActionViewed={handleChatActionViewed}
onChatAction={handleChatAction}
onChatActionDismiss={handleChatActionDismiss}
onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
}}
@@ -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(
<ChatActionCard
action={action}
onViewed={onViewed}
onActivate={onActivate}
onDismiss={onDismiss}
/>,
);
});
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<HTMLButtonElement>(
'button[aria-label="Dismiss"]',
);
act(() => dismissButton?.click());
expect(onDismiss).toHaveBeenCalledWith(action);
expect(container.innerHTML).toBe("");
});
});
@@ -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<ChatAction["type"], LucideIcon> = {
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<void>;
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<string | null>(null);
const viewedActionIdRef = useRef<string | null>(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 (
<aside
className={styles.commercialAction}
aria-label={action.ctaLabel}
data-chat-action={action.type}
data-chat-action-kind={action.kind}
>
<div className={styles.commercialActionHeader}>
<Icon size={18} aria-hidden="true" />
<button
type="button"
className={styles.commercialActionDismiss}
title="Dismiss"
aria-label="Dismiss"
onClick={() => {
setDismissed(true);
onDismiss?.(action);
}}
>
<X size={16} aria-hidden="true" />
</button>
</div>
<p className={styles.commercialActionCopy}>{action.copy}</p>
{activationError ? (
<p className={styles.commercialActionError} role="alert">
{activationError}
</p>
) : null}
<button
type="button"
className={styles.commercialActionButton}
disabled={activating}
onClick={() => {
setActivating(true);
setActivationError(null);
void Promise.resolve(onActivate?.(action))
.catch(() =>
setActivationError(
"This option is unavailable right now. Please try again.",
),
)
.finally(() => setActivating(false));
}}
>
<span>{action.ctaLabel}</span>
{activating ? (
<LoaderCircle
className={styles.commercialActionSpinner}
size={17}
aria-hidden="true"
/>
) : (
<ArrowRight size={17} aria-hidden="true" />
)}
</button>
</aside>
);
}
+17 -1
View File
@@ -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<void>;
onChatActionDismiss?: (action: ChatAction) => void;
}
type ChatMessageAction = (
@@ -90,6 +93,9 @@ export function ChatArea({
onCharacterAvatarClick,
onCommercialAction,
onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(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<void>,
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}
/>
),
);
+1
View File
@@ -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";
+13 -1
View File
@@ -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<void>;
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}
/>
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div>
+18 -2
View File
@@ -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<void>;
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 ? (
<ChatActionCard
action={chatAction}
onViewed={onChatActionViewed}
onActivate={onChatAction}
onDismiss={onChatActionDismiss}
/>
) : isFromAI && commercialAction ? (
<CommercialActionCard
action={commercialAction}
onActivate={onCommercialAction}
@@ -14,9 +14,20 @@ vi.mock("@/stores/auth/auth-context", () => ({
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");
});
});
+58 -22
View File
@@ -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;
@@ -1,2 +0,0 @@
export const FREE_STANDARD_CHAT_DAILY_COUNT = 30;
export const FREE_PRIVATE_MESSAGE_DAILY_COUNT = 2;
+4
View File
@@ -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}
/>
);
}
@@ -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 =
@@ -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<string | null>(null);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
+3
View File
@@ -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 =
+10
View File
@@ -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;
@@ -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",
@@ -31,6 +31,7 @@ export interface IPaymentRepository {
autoRenew: boolean,
recipientCharacterId?: string,
commercialOfferId?: string,
chatActionId?: string,
): Promise<Result<CreatePaymentOrderResponse>>;
/** 查询支付订单状态。 */
@@ -63,6 +63,7 @@ export class PaymentRepository implements IPaymentRepository {
autoRenew: boolean,
recipientCharacterId?: string,
commercialOfferId?: string,
chatActionId?: string,
): Promise<Result<CreatePaymentOrderResponse>> {
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));
}
@@ -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();
});
});
+75
View File
@@ -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<typeof ChatActionSchema>;
export type ChatActionEventType = z.output<typeof ChatActionEventTypeSchema>;
export type ChatActionEventRequest = z.input<
typeof ChatActionEventRequestSchema
>;
export type ChatActionEventResponse = z.output<
typeof ChatActionEventResponseSchema
>;
+1
View File
@@ -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";
@@ -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();
@@ -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();
});
});
@@ -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();
@@ -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,
+1
View File
@@ -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" },
+3
View File
@@ -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;
+19
View File
@@ -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<string, unknown>);
}
/** 上报白名单聊天操作卡事件;eventId 用于安全重试。 */
async recordActionEvent(
body: ChatActionEventRequest,
): Promise<ChatActionEventResponse> {
const request = ChatActionEventRequestSchema.parse(body);
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatActionEvents,
{
method: "POST",
body: request,
},
);
return ChatActionEventResponseSchema.parse(unwrap(env));
}
/** 幂等保存当前角色的首次开场白。 */
async saveOpeningMessage(
body: OpeningMessageRequest,
@@ -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();
});
});
+100
View File
@@ -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<void> {
await recordChatActionEventById(action.actionId, characterId, eventType);
}
export async function recordChatActionEventById(
actionId: string,
characterId: string,
eventType: ChatActionEventType,
): Promise<void> {
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<PendingChatActionArrival>;
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);
});
}
@@ -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;
}
+2
View File
@@ -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) {
<AuthStatusChecker />
<OAuthSessionSync />
<AppNavigationGuard />
<ChatActionArrivalTracker />
<SplashLatestMessageProvider>
<UserProvider>
<UserAuthSync />
+8
View File
@@ -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,
@@ -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({
+3 -1
View File
@@ -74,7 +74,9 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
...(response.audioUrl && !response.lockDetail.locked
? { audioUrl: response.audioUrl }
: {}),
...(response.commercialAction
...(response.chatAction
? { chatAction: response.chatAction }
: response.commercialAction
? { commercialAction: response.commercialAction }
: {}),
...deriveUiLockFields(response.lockDetail, response.image.url),
+2 -1
View File
@@ -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<typeof UiMessageSchema>;
@@ -28,6 +28,7 @@ export interface CreateOrderInput {
autoRenew: boolean;
recipientCharacterId?: string;
commercialOfferId?: string;
chatActionId?: string;
}
export type CreateOrderSpy = (input: CreateOrderInput) => void;
@@ -105,6 +105,30 @@ describe("payment order flow", () => {
actor.stop();
});
it("carries the originating chat action into order creation", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
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(
@@ -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;
+4 -1
View File
@@ -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: [],
+3
View File
@@ -198,6 +198,9 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
...(context.commercialOfferId
? { commercialOfferId: context.commercialOfferId }
: {}),
...(context.chatActionId
? { chatActionId: context.chatActionId }
: {}),
}),
onDone: {
target: "pollingOrder",
+1
View File
@@ -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 }
+2
View File
@@ -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",
+10 -3
View File
@@ -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<UserFetchData>(async () => {
@@ -31,14 +35,17 @@ export const userFetchActor = fromPromise<UserFetchData>(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 };
});
+1
View File
@@ -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,
};
},
+1
View File
@@ -10,6 +10,7 @@ const clearUserAction = profileMachineSetup.assign(() => ({
avatarUrl: null,
isVip: false,
creditBalance: 0,
entitlements: null,
}));
export const userMachineSetup = profileMachineSetup.extend({
+2
View File
@@ -18,6 +18,7 @@ interface UserState {
avatarUrl: string | null;
isVip: boolean;
creditBalance: number;
entitlements: MachineContext["entitlements"];
}
type UserSnapshot = SnapshotFrom<typeof userMachine>;
@@ -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,
};
}
+3
View File
@@ -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,
};