feat(chat): add support action cards and live entitlements
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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] =
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -198,6 +198,9 @@ const creatingOrderState = paymentMachineSetup.createStateConfig({
|
||||
...(context.commercialOfferId
|
||||
? { commercialOfferId: context.commercialOfferId }
|
||||
: {}),
|
||||
...(context.chatActionId
|
||||
? { chatActionId: context.chatActionId }
|
||||
: {}),
|
||||
}),
|
||||
onDone: {
|
||||
target: "pollingOrder",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ const clearUserAction = profileMachineSetup.assign(() => ({
|
||||
avatarUrl: null,
|
||||
isVip: false,
|
||||
creditBalance: 0,
|
||||
entitlements: null,
|
||||
}));
|
||||
|
||||
export const userMachineSetup = profileMachineSetup.extend({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user