feat(chat): add support action cards and live entitlements
This commit is contained in:
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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] =
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 "./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,
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 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 />
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ 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
|
||||||
|
? { chatAction: response.chatAction }
|
||||||
|
: response.commercialAction
|
||||||
? { commercialAction: response.commercialAction }
|
? { commercialAction: response.commercialAction }
|
||||||
: {}),
|
: {}),
|
||||||
...deriveUiLockFields(response.lockDetail, response.image.url),
|
...deriveUiLockFields(response.lockDetail, response.image.url),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user