feat(chat): add support action cards and live entitlements

This commit is contained in:
Codex
2026-07-24 17:59:57 +08:00
parent 19b8fc51d6
commit 76bffc1a0d
52 changed files with 1151 additions and 40 deletions
+14
View File
@@ -23,6 +23,8 @@ export interface UsePaymentRouteFlowInput {
initialCategory?: string | null; initialCategory?: string | null;
initialPlanId?: string | null; initialPlanId?: string | null;
commercialOfferId?: string | null; commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
} }
export interface PaymentRouteFlow { export interface PaymentRouteFlow {
@@ -43,16 +45,20 @@ export function usePaymentRouteFlow({
initialCategory = null, initialCategory = null,
initialPlanId = null, initialPlanId = null,
commercialOfferId = null, commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
}: UsePaymentRouteFlowInput): PaymentRouteFlow { }: UsePaymentRouteFlowInput): PaymentRouteFlow {
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
const initializedCatalogKeyRef = useRef<string | null>(null); const initializedCatalogKeyRef = useRef<string | null>(null);
const resumedOrderIdRef = useRef<string | null>(null);
const catalogKey = [ const catalogKey = [
catalog, catalog,
characterId ?? "", characterId ?? "",
initialCategory ?? "", initialCategory ?? "",
initialPlanId ?? "", initialPlanId ?? "",
commercialOfferId ?? "", commercialOfferId ?? "",
chatActionId ?? "",
].join(":"); ].join(":");
usePendingPaymentOrderLifecycle({ usePendingPaymentOrderLifecycle({
@@ -73,17 +79,25 @@ export function usePaymentRouteFlow({
...(initialCategory ? { category: initialCategory } : {}), ...(initialCategory ? { category: initialCategory } : {}),
...(initialPlanId ? { planId: initialPlanId } : {}), ...(initialPlanId ? { planId: initialPlanId } : {}),
...(commercialOfferId ? { commercialOfferId } : {}), ...(commercialOfferId ? { commercialOfferId } : {}),
...(chatActionId ? { chatActionId } : {}),
}); });
}, [ }, [
catalog, catalog,
catalogKey, catalogKey,
characterId, characterId,
commercialOfferId, commercialOfferId,
chatActionId,
initialCategory, initialCategory,
initialPlanId, initialPlanId,
initialPayChannel, initialPayChannel,
paymentDispatch, paymentDispatch,
]); ]);
useEffect(() => {
if (!resumeOrderId || resumedOrderIdRef.current === resumeOrderId) return;
resumedOrderIdRef.current = resumeOrderId;
paymentDispatch({ type: "PaymentReturned", orderId: resumeOrderId });
}, [paymentDispatch, resumeOrderId]);
return { payment, paymentDispatch }; return { payment, paymentDispatch };
} }
@@ -23,6 +23,7 @@ export default async function CharacterTipPage({
const initialPlanId = normalizeTipGiftParam( const initialPlanId = normalizeTipGiftParam(
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]), getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
); );
const chatActionId = getFirstPaymentSearchParam(query.chatActionId);
return ( return (
<TipScreen <TipScreen
@@ -30,6 +31,7 @@ export default async function CharacterTipPage({
initialPlanId={initialPlanId} initialPlanId={initialPlanId}
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder} shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
initialPayChannel={paymentReturn.initialPayChannel} 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 { buildGlobalPageUrl } from "@/router/global-route-context";
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver"; import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
import { getCharacterRoutes, ROUTE_BUILDERS, ROUTES } from "@/router/routes"; 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 { paymentApi } from "@/data/services/api";
import { behaviorAnalytics } from "@/lib/analytics"; import { behaviorAnalytics } from "@/lib/analytics";
import {
recordChatActionEvent,
rememberChatActionArrival,
} from "@/lib/chat/chat_action_events";
import { MobileShell } from "@/app/_components/core"; 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 { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap"; import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync"; import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync";
import { resolveChatActionTarget } from "./chat-action-navigation";
const chatShellStyle = { const chatShellStyle = {
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)", "--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
@@ -65,6 +70,14 @@ export function ChatScreen() {
ROUTES.profile, ROUTES.profile,
characterRoutes.chat, characterRoutes.chat,
); );
const feedbackUrl = buildGlobalPageUrl(
ROUTES.feedback,
characterRoutes.chat,
);
const coinsRulesUrl = buildGlobalPageUrl(
ROUTES.coinsRules,
characterRoutes.chat,
);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const state = useChatState(); const state = useChatState();
const chatDispatch = useChatDispatch(); 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 ( return (
<MobileShell> <MobileShell>
<div <div
@@ -375,6 +451,9 @@ export function ChatScreen() {
onCharacterAvatarClick={handleOpenCharacterPrivateZone} onCharacterAvatarClick={handleOpenCharacterPrivateZone}
onCommercialAction={handleCommercialAction} onCommercialAction={handleCommercialAction}
onCommercialActionDismiss={handleCommercialActionDismiss} onCommercialActionDismiss={handleCommercialActionDismiss}
onChatActionViewed={handleChatActionViewed}
onChatAction={handleChatAction}
onChatActionDismiss={handleChatActionDismiss}
onLoadMoreHistory={() => { onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" }); 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 { LoaderCircle } from "lucide-react";
import type { UiMessage } from "@/stores/chat/ui-message"; 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 { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { import {
@@ -64,6 +64,9 @@ export interface ChatAreaProps {
onCharacterAvatarClick?: () => void; onCharacterAvatarClick?: () => void;
onCommercialAction?: (action: CommercialAction) => void; onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (action: CommercialAction) => void; onCommercialActionDismiss?: (action: CommercialAction) => void;
onChatActionViewed?: (action: ChatAction) => void;
onChatAction?: (action: ChatAction) => void | Promise<void>;
onChatActionDismiss?: (action: ChatAction) => void;
} }
type ChatMessageAction = ( type ChatMessageAction = (
@@ -90,6 +93,9 @@ export function ChatArea({
onCharacterAvatarClick, onCharacterAvatarClick,
onCommercialAction, onCommercialAction,
onCommercialActionDismiss, onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
}: ChatAreaProps) { }: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
@@ -264,6 +270,9 @@ export function ChatArea({
onCharacterAvatarClick, onCharacterAvatarClick,
onCommercialAction, onCommercialAction,
onCommercialActionDismiss, onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
)} )}
{isReplyingAI ? ( {isReplyingAI ? (
@@ -304,6 +313,9 @@ function renderMessagesWithDateHeaders(
onCharacterAvatarClick?: () => void, onCharacterAvatarClick?: () => void,
onCommercialAction?: (action: CommercialAction) => void, onCommercialAction?: (action: CommercialAction) => void,
onCommercialActionDismiss?: (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) => return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? ( item.type === "date" ? (
@@ -324,6 +336,7 @@ function renderMessagesWithDateHeaders(
lockedPrivate={item.message.lockedPrivate} lockedPrivate={item.message.lockedPrivate}
privateMessageHint={item.message.privateMessageHint} privateMessageHint={item.message.privateMessageHint}
commercialAction={item.message.commercialAction} commercialAction={item.message.commercialAction}
chatAction={item.message.chatAction}
isUnlockingMessage={ isUnlockingMessage={
isUnlockingMessage === true && isUnlockingMessage === true &&
item.message.displayId === unlockingMessageId item.message.displayId === unlockingMessageId
@@ -336,6 +349,9 @@ function renderMessagesWithDateHeaders(
onCharacterAvatarClick={onCharacterAvatarClick} onCharacterAvatarClick={onCharacterAvatarClick}
onCommercialAction={onCommercialAction} onCommercialAction={onCommercialAction}
onCommercialActionDismiss={onCommercialActionDismiss} 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 "./browser-hint-overlay";
export * from "./chat-area"; export * from "./chat-area";
export * from "./commercial-action-card"; export * from "./commercial-action-card";
export * from "./chat-action-card";
export * from "./chat-header"; export * from "./chat-header";
export * from "./chat-insufficient-credits-banner"; export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar"; export * from "./chat-input-bar";
+13 -1
View File
@@ -9,7 +9,7 @@
* - 用户:[占位 spacer] [Content] [Avatar] * - 用户:[占位 spacer] [Content] [Avatar]
*/ */
import { useUserSelector } from "@/stores/user/user-context"; 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 { MessageAvatar } from "./message-avatar";
import { MessageContent } from "./message-content"; import { MessageContent } from "./message-content";
@@ -29,6 +29,7 @@ export interface MessageBubbleProps {
lockedPrivate?: boolean | null; lockedPrivate?: boolean | null;
privateMessageHint?: string | null; privateMessageHint?: string | null;
commercialAction?: CommercialAction | null; commercialAction?: CommercialAction | null;
chatAction?: ChatAction | null;
isUnlockingMessage?: boolean; isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction; onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction;
@@ -38,6 +39,9 @@ export interface MessageBubbleProps {
onCharacterAvatarClick?: () => void; onCharacterAvatarClick?: () => void;
onCommercialAction?: (action: CommercialAction) => void; onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (action: CommercialAction) => void; onCommercialActionDismiss?: (action: CommercialAction) => void;
onChatActionViewed?: (action: ChatAction) => void;
onChatAction?: (action: ChatAction) => void | Promise<void>;
onChatActionDismiss?: (action: ChatAction) => void;
} }
type ChatMessageAction = ( type ChatMessageAction = (
@@ -59,6 +63,7 @@ export function MessageBubble({
lockedPrivate, lockedPrivate,
privateMessageHint, privateMessageHint,
commercialAction, commercialAction,
chatAction,
isUnlockingMessage, isUnlockingMessage,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockVoiceMessage, onUnlockVoiceMessage,
@@ -68,6 +73,9 @@ export function MessageBubble({
onCharacterAvatarClick, onCharacterAvatarClick,
onCommercialAction, onCommercialAction,
onCommercialActionDismiss, onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const avatarUrl = useUserSelector((state) => state.context.avatarUrl); const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
@@ -97,6 +105,7 @@ export function MessageBubble({
lockedPrivate={lockedPrivate} lockedPrivate={lockedPrivate}
privateMessageHint={privateMessageHint} privateMessageHint={privateMessageHint}
commercialAction={commercialAction} commercialAction={commercialAction}
chatAction={chatAction}
isUnlockingMessage={isUnlockingMessage} isUnlockingMessage={isUnlockingMessage}
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockVoiceMessage={onUnlockVoiceMessage}
@@ -104,6 +113,9 @@ export function MessageBubble({
onOpenImage={onOpenImage} onOpenImage={onOpenImage}
onCommercialAction={onCommercialAction} onCommercialAction={onCommercialAction}
onCommercialActionDismiss={onCommercialActionDismiss} onCommercialActionDismiss={onCommercialActionDismiss}
onChatActionViewed={onChatActionViewed}
onChatAction={onChatAction}
onChatActionDismiss={onChatActionDismiss}
/> />
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" /> <div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div> </div>
+18 -2
View File
@@ -1,6 +1,7 @@
"use client"; "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 { CommercialActionCard } from "./commercial-action-card";
import { ImageBubble } from "./image-bubble"; import { ImageBubble } from "./image-bubble";
import { LockedImageMessageCard } from "./locked-image-message-card"; import { LockedImageMessageCard } from "./locked-image-message-card";
@@ -23,6 +24,7 @@ export interface MessageContentProps {
lockedPrivate?: boolean | null; lockedPrivate?: boolean | null;
privateMessageHint?: string | null; privateMessageHint?: string | null;
commercialAction?: CommercialAction | null; commercialAction?: CommercialAction | null;
chatAction?: ChatAction | null;
isUnlockingMessage?: boolean; isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction; onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: ChatMessageAction;
@@ -30,6 +32,9 @@ export interface MessageContentProps {
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (displayMessageId: string) => void;
onCommercialAction?: (action: CommercialAction) => void; onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (action: CommercialAction) => void; onCommercialActionDismiss?: (action: CommercialAction) => void;
onChatActionViewed?: (action: ChatAction) => void;
onChatAction?: (action: ChatAction) => void | Promise<void>;
onChatActionDismiss?: (action: ChatAction) => void;
} }
type ChatMessageAction = ( type ChatMessageAction = (
@@ -53,6 +58,7 @@ export function MessageContent({
lockedPrivate, lockedPrivate,
privateMessageHint, privateMessageHint,
commercialAction, commercialAction,
chatAction,
isUnlockingMessage, isUnlockingMessage,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockVoiceMessage, onUnlockVoiceMessage,
@@ -60,6 +66,9 @@ export function MessageContent({
onOpenImage, onOpenImage,
onCommercialAction, onCommercialAction,
onCommercialActionDismiss, onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
}: MessageContentProps) { }: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0; const hasImage = imageUrl != null && imageUrl.length > 0;
const hasAudio = audioUrl != null && audioUrl.length > 0; const hasAudio = audioUrl != null && audioUrl.length > 0;
@@ -134,7 +143,14 @@ export function MessageContent({
) : null} ) : null}
</> </>
)} )}
{isFromAI && commercialAction ? ( {isFromAI && chatAction ? (
<ChatActionCard
action={chatAction}
onViewed={onChatActionViewed}
onActivate={onChatAction}
onDismiss={onChatActionDismiss}
/>
) : isFromAI && commercialAction ? (
<CommercialActionCard <CommercialActionCard
action={commercialAction} action={commercialAction}
onActivate={onCommercialAction} onActivate={onCommercialAction}
@@ -14,9 +14,20 @@ vi.mock("@/stores/auth/auth-context", () => ({
vi.mock("@/stores/user/user-context", () => ({ vi.mock("@/stores/user/user-context", () => ({
useUserDispatch: () => vi.fn(), useUserDispatch: () => vi.fn(),
useUserState: () => ({ useUserState: () => ({
currentUser: { currentUser: null,
dailyFreeChatLimit: 30, entitlements: {
dailyFreePrivateLimit: 2, 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( expect(html).toContain(
'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"', '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 { useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-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"; import styles from "./coins-rules-screen.module.css";
interface CoinRuleItem { interface CoinRuleItem {
@@ -35,25 +31,44 @@ interface CoinRuleItem {
const formatDailyFreeMessages = (count: number, label: string): string => const formatDailyFreeMessages = (count: number, label: string): string =>
`${Math.max(0, Math.trunc(count))} free ${label} daily`; `${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 = ( const createCoinRules = (
dailyFreeChatLimit: number, dailyFreeChatLimit: number | null,
dailyFreePrivateLimit: number, dailyFreePrivateLimit: number | null,
costs: {
normalMessage: number | null;
privateMessage: number | null;
voiceMessage: number | null;
photo: number | null;
privateAlbum10: number | null;
privateAlbum20: number | null;
},
): readonly CoinRuleItem[] => [ ): readonly CoinRuleItem[] => [
{ {
title: "Standard Chat", title: "Standard Chat",
cost: "2 coins / message", cost: formatCoinCost(costs.normalMessage, "message"),
free: formatDailyFreeMessages(dailyFreeChatLimit, "messages"), free:
dailyFreeChatLimit === null
? "Current daily allowance loads from your account"
: formatDailyFreeMessages(dailyFreeChatLimit, "messages"),
icon: MessageCircle, icon: MessageCircle,
}, },
{ {
title: "Private Chat", title: "Private Chat",
cost: "10 coins / message", cost: formatCoinCost(costs.privateMessage, "message"),
free: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"), free:
dailyFreePrivateLimit === null
? "Current daily allowance loads from your account"
: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"),
icon: Sparkles, icon: Sparkles,
}, },
{ {
title: "Voice Message", title: "Voice Message",
cost: "20 coins / message", cost: formatCoinCost(costs.voiceMessage, "message"),
icon: Mic2, icon: Mic2,
}, },
// { // {
@@ -63,17 +78,23 @@ const createCoinRules = (
// }, // },
{ {
title: "Photo", title: "Photo",
cost: "40 coins / photo", cost: formatCoinCost(costs.photo, "photo"),
icon: ImageIcon, icon: ImageIcon,
}, },
{ {
title: "Private Album", 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, icon: ImageIcon,
}, },
{ {
title: "Private Album", 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, icon: ImageIcon,
}, },
] as const; ] as const;
@@ -87,20 +108,35 @@ export function CoinsRulesScreen({ returnTo }: CoinsRulesScreenProps) {
const authState = useAuthState(); const authState = useAuthState();
const userState = useUserState(); const userState = useUserState();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const entitlements = userState.entitlements;
const dailyFreeChatLimit = const dailyFreeChatLimit =
userState.currentUser?.dailyFreeChatLimit ?? FREE_STANDARD_CHAT_DAILY_COUNT; entitlements?.quotas.normalChatFreeDaily ??
userState.currentUser?.dailyFreeChatLimit ??
null;
const dailyFreePrivateLimit = const dailyFreePrivateLimit =
entitlements?.quotas.privateUnlockFreeDaily ??
userState.currentUser?.dailyFreePrivateLimit ?? userState.currentUser?.dailyFreePrivateLimit ??
FREE_PRIVATE_MESSAGE_DAILY_COUNT; null;
const standardChatLabel = formatDailyFreeMessages( 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, dailyFreeChatLimit,
"messages",
);
const privateChatLabel = formatDailyFreeMessages(
dailyFreePrivateLimit, 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(() => { useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return; 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( const commercialOfferId = getFirstPaymentSearchParam(
query.commercialOfferId, query.commercialOfferId,
); );
const resumeOrderId = getFirstPaymentSearchParam(query.resumeOrderId);
const chatActionId = getFirstPaymentSearchParam(query.chatActionId);
const analyticsContext = parsePaymentAnalyticsContext({ const analyticsContext = parsePaymentAnalyticsContext({
entryPoint: getFirstPaymentSearchParam( entryPoint: getFirstPaymentSearchParam(
query[PAYMENT_ANALYTICS_ENTRY_PARAM], query[PAYMENT_ANALYTICS_ENTRY_PARAM],
@@ -56,6 +58,8 @@ export default async function SubscriptionPage({
sourceCharacterSlug={sourceCharacterSlug} sourceCharacterSlug={sourceCharacterSlug}
initialPlanId={initialPlanId} initialPlanId={initialPlanId}
commercialOfferId={commercialOfferId} commercialOfferId={commercialOfferId}
resumeOrderId={resumeOrderId}
chatActionId={chatActionId}
/> />
); );
} }
@@ -46,6 +46,8 @@ export interface SubscriptionScreenProps {
sourceCharacterSlug?: string; sourceCharacterSlug?: string;
initialPlanId?: string | null; initialPlanId?: string | null;
commercialOfferId?: string | null; commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
} }
export function SubscriptionScreen({ export function SubscriptionScreen({
@@ -57,6 +59,8 @@ export function SubscriptionScreen({
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
initialPlanId = null, initialPlanId = null,
commercialOfferId = null, commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
}: SubscriptionScreenProps) { }: SubscriptionScreenProps) {
const userState = useUserState(); const userState = useUserState();
const countryCode = userState.currentUser?.countryCode; const countryCode = userState.currentUser?.countryCode;
@@ -78,6 +82,8 @@ export function SubscriptionScreen({
initialPayChannel: paymentMethodConfig.initialPayChannel, initialPayChannel: paymentMethodConfig.initialPayChannel,
initialPlanId, initialPlanId,
commercialOfferId, commercialOfferId,
resumeOrderId,
chatActionId,
}); });
const canSubscribeVip = subscriptionType === "vip"; const canSubscribeVip = subscriptionType === "vip";
const analyticsContext = const analyticsContext =
@@ -22,6 +22,8 @@ export interface UseSubscriptionPaymentFlowInput {
sourceCharacterSlug?: string; sourceCharacterSlug?: string;
initialPlanId?: string | null; initialPlanId?: string | null;
commercialOfferId?: string | null; commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
} }
export function useSubscriptionPaymentFlow({ export function useSubscriptionPaymentFlow({
@@ -32,6 +34,8 @@ export function useSubscriptionPaymentFlow({
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG, sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
initialPlanId = null, initialPlanId = null,
commercialOfferId = null, commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
}: UseSubscriptionPaymentFlowInput) { }: UseSubscriptionPaymentFlowInput) {
const router = useRouter(); const router = useRouter();
const { payment, paymentDispatch } = usePaymentRouteFlow({ const { payment, paymentDispatch } = usePaymentRouteFlow({
@@ -40,6 +44,8 @@ export function useSubscriptionPaymentFlow({
shouldResumePendingOrder, shouldResumePendingOrder,
initialPlanId, initialPlanId,
commercialOfferId, commercialOfferId,
resumeOrderId,
chatActionId,
}); });
const successDialogShownOrderRef = useRef<string | null>(null); const successDialogShownOrderRef = useRef<string | null>(null);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
+3
View File
@@ -39,6 +39,7 @@ export interface TipScreenProps {
initialPlanId?: string | null; initialPlanId?: string | null;
shouldResumePendingOrder?: boolean; shouldResumePendingOrder?: boolean;
initialPayChannel?: PayChannel | null; initialPayChannel?: PayChannel | null;
chatActionId?: string | null;
} }
export function TipScreen({ export function TipScreen({
@@ -46,6 +47,7 @@ export function TipScreen({
initialPlanId = null, initialPlanId = null,
shouldResumePendingOrder = false, shouldResumePendingOrder = false,
initialPayChannel = null, initialPayChannel = null,
chatActionId = null,
}: TipScreenProps) { }: TipScreenProps) {
const character = useActiveCharacter(); const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
@@ -63,6 +65,7 @@ export function TipScreen({
initialPayChannel: paymentMethodConfig.initialPayChannel, initialPayChannel: paymentMethodConfig.initialPayChannel,
paymentType: "tip", paymentType: "tip",
shouldResumePendingOrder, shouldResumePendingOrder,
chatActionId,
}); });
const selectedCategory = const selectedCategory =
+10
View File
@@ -14,6 +14,8 @@ import { AppEnvUtil } from "@/utils/app-env";
import { Logger } from "@/utils/logger"; import { Logger } from "@/utils/logger";
import { import {
CommercialActionSchema, CommercialActionSchema,
ChatActionSchema,
type ChatAction,
type CommercialAction, type CommercialAction,
} from "@/data/schemas/chat"; } from "@/data/schemas/chat";
@@ -45,6 +47,7 @@ export class ChatWebSocket {
onImage: ((url: string) => void) | null = null; onImage: ((url: string) => void) | null = null;
onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null; onPaywallStatus: ((payload: PaywallStatusPayload) => void) | null = null;
onCommercialAction: ((action: CommercialAction) => void) | null = null; onCommercialAction: ((action: CommercialAction) => void) | null = null;
onChatAction: ((action: ChatAction) => void) | null = null;
onError: ((errorMessage: string) => void) | null = null; onError: ((errorMessage: string) => void) | null = null;
constructor( constructor(
@@ -151,6 +154,8 @@ export class ChatWebSocket {
ctaLabel?: string; ctaLabel?: string;
target?: string; target?: string;
ruleId?: string; ruleId?: string;
kind?: string;
orderId?: string | null;
}; };
}; };
try { try {
@@ -195,6 +200,11 @@ export class ChatWebSocket {
if (action.success) this.onCommercialAction?.(action.data); if (action.success) this.onCommercialAction?.(action.data);
break; break;
} }
case "chat_action": {
const action = ChatActionSchema.safeParse(payload.data);
if (action.success) this.onChatAction?.(action.data);
break;
}
case "error": case "error":
this.onError?.(payload.error ?? "Unknown error"); this.onError?.(payload.error ?? "Unknown error");
break; break;
@@ -9,6 +9,33 @@ import type { PaymentApi } from "@/data/services/api";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
describe("PaymentRepository", () => { 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 () => { it("loads a character gift catalog without adapting product metadata", async () => {
const catalog = GiftProductsResponseSchema.parse({ const catalog = GiftProductsResponseSchema.parse({
characterId: "elio", characterId: "elio",
@@ -31,6 +31,7 @@ export interface IPaymentRepository {
autoRenew: boolean, autoRenew: boolean,
recipientCharacterId?: string, recipientCharacterId?: string,
commercialOfferId?: string, commercialOfferId?: string,
chatActionId?: string,
): Promise<Result<CreatePaymentOrderResponse>>; ): Promise<Result<CreatePaymentOrderResponse>>;
/** 查询支付订单状态。 */ /** 查询支付订单状态。 */
@@ -63,6 +63,7 @@ export class PaymentRepository implements IPaymentRepository {
autoRenew: boolean, autoRenew: boolean,
recipientCharacterId?: string, recipientCharacterId?: string,
commercialOfferId?: string, commercialOfferId?: string,
chatActionId?: string,
): Promise<Result<CreatePaymentOrderResponse>> { ): Promise<Result<CreatePaymentOrderResponse>> {
const request = CreatePaymentOrderRequestSchema.parse({ const request = CreatePaymentOrderRequestSchema.parse({
planId, planId,
@@ -70,6 +71,7 @@ export class PaymentRepository implements IPaymentRepository {
autoRenew, autoRenew,
...(recipientCharacterId ? { recipientCharacterId } : {}), ...(recipientCharacterId ? { recipientCharacterId } : {}),
...(commercialOfferId ? { commercialOfferId } : {}), ...(commercialOfferId ? { commercialOfferId } : {}),
...(chatActionId ? { chatActionId } : {}),
}); });
return Result.wrap(() => this.api.createOrder(request)); 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 "./chat_message";
export * from "./opening_message"; export * from "./opening_message";
export * from "./chat_payloads"; export * from "./chat_payloads";
export * from "./chat_action";
export * from "./request/send_message_request"; export * from "./request/send_message_request";
export * from "./request/unlock_history_request"; export * from "./request/unlock_history_request";
export * from "./request/unlock_private_request"; export * from "./request/unlock_private_request";
@@ -11,6 +11,7 @@ import {
stringOrEmpty, stringOrEmpty,
} from "../../nullable-defaults"; } from "../../nullable-defaults";
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads"; import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
import { ChatActionSchema } from "../chat_action";
export const CommercialActionSchema = z export const CommercialActionSchema = z
.object({ .object({
@@ -41,6 +42,7 @@ export const ChatSendResponseSchema = z
requiredCredits: numberOrZero, requiredCredits: numberOrZero,
shortfallCredits: numberOrZero, shortfallCredits: numberOrZero,
commercialAction: CommercialActionSchema.nullish().default(null), commercialAction: CommercialActionSchema.nullish().default(null),
chatAction: ChatActionSchema.nullish().default(null),
}) })
.readonly(); .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(), autoRenew: z.boolean(),
recipientCharacterId: z.string().min(1).optional(), recipientCharacterId: z.string().min(1).optional(),
commercialOfferId: z.string().min(1).optional(), commercialOfferId: z.string().min(1).optional(),
chatActionId: z.uuid().optional(),
}) })
.readonly(); .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 () => { it("persists the character opening message with the canonical contract", async () => {
httpClientMock.mockResolvedValue({ httpClientMock.mockResolvedValue({
success: true, success: true,
+1
View File
@@ -19,6 +19,7 @@
"paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" }, "paymentTipMessage": { "method": "post", "path": "/api/payment/tip-message" },
"paymentCommercialOfferAccept": { "method": "post", "path": "/api/payment/commercial-offers/{offerId}/accept" }, "paymentCommercialOfferAccept": { "method": "post", "path": "/api/payment/commercial-offers/{offerId}/accept" },
"chatSend": { "method": "post", "path": "/api/chat/send" }, "chatSend": { "method": "post", "path": "/api/chat/send" },
"chatActionEvents": { "method": "post", "path": "/api/chat/action-events" },
"chatOpeningMessage": { "method": "post", "path": "/api/chat/opening-message" }, "chatOpeningMessage": { "method": "post", "path": "/api/chat/opening-message" },
"chatHistory": { "method": "get", "path": "/api/chat/history" }, "chatHistory": { "method": "get", "path": "/api/chat/history" },
"chatUnlockPrivate": { "method": "post", "path": "/api/chat/unlock-private" }, "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 chatSend = apiContract.chatSend.path;
/** 记录角色聊天操作卡漏斗事件 */
static readonly chatActionEvents = apiContract.chatActionEvents.path;
/** 幂等保存角色开场白 */ /** 幂等保存角色开场白 */
static readonly chatOpeningMessage = apiContract.chatOpeningMessage.path; static readonly chatOpeningMessage = apiContract.chatOpeningMessage.path;
+19
View File
@@ -11,6 +11,10 @@ import {
ChatPreviewsResponseSchema, ChatPreviewsResponseSchema,
ChatSendResponse, ChatSendResponse,
ChatSendResponseSchema, ChatSendResponseSchema,
ChatActionEventRequest,
ChatActionEventRequestSchema,
ChatActionEventResponse,
ChatActionEventResponseSchema,
OpeningMessageRequest, OpeningMessageRequest,
OpeningMessageResponse, OpeningMessageResponse,
OpeningMessageResponseSchema, OpeningMessageResponseSchema,
@@ -42,6 +46,21 @@ export class ChatApi {
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>); 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( async saveOpeningMessage(
body: OpeningMessageRequest, 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 type { ReactNode } from "react";
import { CharacterCatalogProvider } from "@/providers/character-catalog-provider"; import { CharacterCatalogProvider } from "@/providers/character-catalog-provider";
import { ChatActionArrivalTracker } from "@/providers/chat-action-arrival-tracker";
import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider"; import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider";
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider"; import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
import { AppNavigationGuard } from "@/router/app-navigation-guard"; import { AppNavigationGuard } from "@/router/app-navigation-guard";
@@ -31,6 +32,7 @@ export function RootProviders({ children }: RootProvidersProps) {
<AuthStatusChecker /> <AuthStatusChecker />
<OAuthSessionSync /> <OAuthSessionSync />
<AppNavigationGuard /> <AppNavigationGuard />
<ChatActionArrivalTracker />
<SplashLatestMessageProvider> <SplashLatestMessageProvider>
<UserProvider> <UserProvider>
<UserAuthSync /> <UserAuthSync />
+8
View File
@@ -87,6 +87,8 @@ export const ROUTE_BUILDERS = {
analytics?: PaymentAnalyticsContext; analytics?: PaymentAnalyticsContext;
planId?: string; planId?: string;
commercialOfferId?: string; commercialOfferId?: string;
resumeOrderId?: string;
chatActionId?: string;
} = {}, } = {},
): `/subscription?${string}` => { ): `/subscription?${string}` => {
const params = new URLSearchParams({ type }); const params = new URLSearchParams({ type });
@@ -99,6 +101,12 @@ export const ROUTE_BUILDERS = {
if (options.commercialOfferId) { if (options.commercialOfferId) {
params.set("commercialOfferId", 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) { if (options.analytics) {
params.set( params.set(
PAYMENT_ANALYTICS_ENTRY_PARAM, 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", () => { it("maps locked voice messages without treating them as private text", () => {
const message = sendResponseToUiMessage( const message = sendResponseToUiMessage(
makeResponse({ makeResponse({
+5 -3
View File
@@ -74,9 +74,11 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
...(response.audioUrl && !response.lockDetail.locked ...(response.audioUrl && !response.lockDetail.locked
? { audioUrl: response.audioUrl } ? { audioUrl: response.audioUrl }
: {}), : {}),
...(response.commercialAction ...(response.chatAction
? { commercialAction: response.commercialAction } ? { chatAction: response.chatAction }
: {}), : response.commercialAction
? { commercialAction: response.commercialAction }
: {}),
...deriveUiLockFields(response.lockDetail, response.image.url), ...deriveUiLockFields(response.lockDetail, response.image.url),
}; };
} }
+2 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { CommercialActionSchema } from "@/data/schemas/chat"; import { ChatActionSchema, CommercialActionSchema } from "@/data/schemas/chat";
export const UiMessageSchema = z.object({ export const UiMessageSchema = z.object({
displayId: z.string().min(1), displayId: z.string().min(1),
@@ -18,6 +18,7 @@ export const UiMessageSchema = z.object({
lockedPrivate: z.boolean().nullable().optional(), lockedPrivate: z.boolean().nullable().optional(),
privateMessageHint: z.string().nullable().optional(), privateMessageHint: z.string().nullable().optional(),
commercialAction: CommercialActionSchema.nullable().optional(), commercialAction: CommercialActionSchema.nullable().optional(),
chatAction: ChatActionSchema.nullable().optional(),
}); });
export type UiMessage = z.infer<typeof UiMessageSchema>; export type UiMessage = z.infer<typeof UiMessageSchema>;
@@ -28,6 +28,7 @@ export interface CreateOrderInput {
autoRenew: boolean; autoRenew: boolean;
recipientCharacterId?: string; recipientCharacterId?: string;
commercialOfferId?: string; commercialOfferId?: string;
chatActionId?: string;
} }
export type CreateOrderSpy = (input: CreateOrderInput) => void; export type CreateOrderSpy = (input: CreateOrderInput) => void;
@@ -105,6 +105,30 @@ describe("payment order flow", () => {
actor.stop(); 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 () => { it("loads a stable Tip message after payment and clears it on reset", async () => {
const loadTipMessage = vi.fn(); const loadTipMessage = vi.fn();
const actor = createActor( const actor = createActor(
@@ -16,6 +16,7 @@ export const createPaymentOrderActor = fromPromise<
autoRenew: boolean; autoRenew: boolean;
recipientCharacterId?: string; recipientCharacterId?: string;
commercialOfferId?: string; commercialOfferId?: string;
chatActionId?: string;
} }
>(async ({ input }) => { >(async ({ input }) => {
const result = await getPaymentRepository().createOrder( const result = await getPaymentRepository().createOrder(
@@ -24,6 +25,7 @@ export const createPaymentOrderActor = fromPromise<
input.autoRenew, input.autoRenew,
input.recipientCharacterId, input.recipientCharacterId,
input.commercialOfferId, input.commercialOfferId,
input.chatActionId,
); );
if (Result.isErr(result)) throw result.error; if (Result.isErr(result)) throw result.error;
return result.data; return result.data;
+4 -1
View File
@@ -38,17 +38,20 @@ function initializeCatalogState(
const commercialOfferId = const commercialOfferId =
planCatalog === "default" ? (event.commercialOfferId ?? null) : null; planCatalog === "default" ? (event.commercialOfferId ?? null) : null;
const offerChanged = commercialOfferId !== context.commercialOfferId; const offerChanged = commercialOfferId !== context.commercialOfferId;
const chatActionId = event.chatActionId ?? null;
const chatActionChanged = chatActionId !== context.chatActionId;
return { return {
planCatalog, planCatalog,
...(event.payChannel ? { payChannel: event.payChannel } : {}), ...(event.payChannel ? { payChannel: event.payChannel } : {}),
giftCharacterId, giftCharacterId,
commercialOfferId, commercialOfferId,
chatActionId,
requestedGiftCategory: requestedGiftCategory:
planCatalog === "tip" ? (event.category ?? null) : null, planCatalog === "tip" ? (event.category ?? null) : null,
requestedGiftPlanId: requestedGiftPlanId:
planCatalog === "tip" ? (event.planId ?? null) : null, planCatalog === "tip" ? (event.planId ?? null) : null,
...(catalogChanged || characterChanged || selectionChanged || offerChanged ...(catalogChanged || characterChanged || selectionChanged || offerChanged || chatActionChanged
? { ? {
...resetOrderState(), ...resetOrderState(),
plans: [], plans: [],
+3
View File
@@ -199,6 +199,9 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
...(context.commercialOfferId ...(context.commercialOfferId
? { commercialOfferId: context.commercialOfferId } ? { commercialOfferId: context.commercialOfferId }
: {}), : {}),
...(context.chatActionId
? { chatActionId: context.chatActionId }
: {}),
}), }),
onDone: { onDone: {
target: "pollingOrder", target: "pollingOrder",
+1
View File
@@ -11,6 +11,7 @@ export type PaymentEvent =
category?: string | null; category?: string | null;
planId?: string | null; planId?: string | null;
commercialOfferId?: string | null; commercialOfferId?: string | null;
chatActionId?: string | null;
} }
| { type: "PaymentPlanSelected"; planId: string } | { type: "PaymentPlanSelected"; planId: string }
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel } | { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
+2
View File
@@ -22,6 +22,7 @@ export interface PaymentState {
requestedGiftPlanId: string | null; requestedGiftPlanId: string | null;
isFirstRecharge: boolean; isFirstRecharge: boolean;
commercialOfferId: string | null; commercialOfferId: string | null;
chatActionId: string | null;
commercialOffer: CommercialOfferSummary | null; commercialOffer: CommercialOfferSummary | null;
selectedPlanId: string; selectedPlanId: string;
payChannel: PayChannel; payChannel: PayChannel;
@@ -49,6 +50,7 @@ export const initialState: PaymentState = {
requestedGiftPlanId: null, requestedGiftPlanId: null,
isFirstRecharge: false, isFirstRecharge: false,
commercialOfferId: null, commercialOfferId: null,
chatActionId: null,
commercialOffer: null, commercialOffer: null,
selectedPlanId: "", selectedPlanId: "",
payChannel: "stripe", payChannel: "stripe",
+10 -3
View File
@@ -1,7 +1,10 @@
import { fromPromise } from "xstate"; import { fromPromise } from "xstate";
import { getUserRepository } from "@/data/repositories/user_repository"; 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 { UserStorage } from "@/data/storage/user/user_storage";
import type { UserView } from "@/stores/user/user-view"; import type { UserView } from "@/stores/user/user-view";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
@@ -15,6 +18,7 @@ import {
export interface UserFetchData { export interface UserFetchData {
user: UserView | null; user: UserView | null;
snapshot: UserEntitlementSnapshotData | null; snapshot: UserEntitlementSnapshotData | null;
entitlements?: UserEntitlementsData | null;
} }
export const userFetchActor = fromPromise<UserFetchData>(async () => { export const userFetchActor = fromPromise<UserFetchData>(async () => {
@@ -31,14 +35,17 @@ export const userFetchActor = fromPromise<UserFetchData>(async () => {
creditBalance: entitlementsResult.data.creditBalance, creditBalance: entitlementsResult.data.creditBalance,
}) })
: null; : null;
const entitlements = Result.isOk(entitlementsResult)
? entitlementsResult.data
: null;
if (snapshot) await userStorage.setEntitlementSnapshot(snapshot); 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( const view = applyEntitlementSnapshotToView(
toView(userResult.data), toView(userResult.data),
snapshot, snapshot,
); );
await userStorage.setUser(userResult.data); 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, context.currentUser,
isVip: snapshot?.isVip ?? context.isVip, isVip: snapshot?.isVip ?? context.isVip,
creditBalance: snapshot?.creditBalance ?? context.creditBalance, creditBalance: snapshot?.creditBalance ?? context.creditBalance,
entitlements: event.output.entitlements ?? context.entitlements,
isLoading: false, isLoading: false,
}; };
}, },
+1
View File
@@ -10,6 +10,7 @@ const clearUserAction = profileMachineSetup.assign(() => ({
avatarUrl: null, avatarUrl: null,
isVip: false, isVip: false,
creditBalance: 0, creditBalance: 0,
entitlements: null,
})); }));
export const userMachineSetup = profileMachineSetup.extend({ export const userMachineSetup = profileMachineSetup.extend({
+2
View File
@@ -18,6 +18,7 @@ interface UserState {
avatarUrl: string | null; avatarUrl: string | null;
isVip: boolean; isVip: boolean;
creditBalance: number; creditBalance: number;
entitlements: MachineContext["entitlements"];
} }
type UserSnapshot = SnapshotFrom<typeof userMachine>; type UserSnapshot = SnapshotFrom<typeof userMachine>;
@@ -59,5 +60,6 @@ function selectUserState(state: UserSnapshot): UserState {
avatarUrl: state.context.avatarUrl, avatarUrl: state.context.avatarUrl,
isVip: state.context.isVip, isVip: state.context.isVip,
creditBalance: state.context.creditBalance, creditBalance: state.context.creditBalance,
entitlements: state.context.entitlements,
}; };
} }
+3
View File
@@ -2,6 +2,7 @@
* User State + * User State +
*/ */
import type { UserView } from "@/stores/user/user-view"; import type { UserView } from "@/stores/user/user-view";
import type { UserEntitlementsData } from "@/data/schemas/user";
export interface UserState { export interface UserState {
currentUser: UserView | null; currentUser: UserView | null;
@@ -9,6 +10,7 @@ export interface UserState {
avatarUrl: string | null; avatarUrl: string | null;
isVip: boolean; isVip: boolean;
creditBalance: number; creditBalance: number;
entitlements: UserEntitlementsData | null;
} }
export const initialState: UserState = { export const initialState: UserState = {
@@ -17,4 +19,5 @@ export const initialState: UserState = {
avatarUrl: null, avatarUrl: null,
isVip: false, isVip: false,
creditBalance: 0, creditBalance: 0,
entitlements: null,
}; };