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

This commit is contained in:
Codex
2026-07-24 17:59:57 +08:00
parent 17236bd14e
commit 30ab2c2c97
52 changed files with 1151 additions and 40 deletions
+14
View File
@@ -23,6 +23,8 @@ export interface UsePaymentRouteFlowInput {
initialCategory?: string | null;
initialPlanId?: string | null;
commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
}
export interface PaymentRouteFlow {
@@ -43,16 +45,20 @@ export function usePaymentRouteFlow({
initialCategory = null,
initialPlanId = null,
commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
}: UsePaymentRouteFlowInput): PaymentRouteFlow {
const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const initializedCatalogKeyRef = useRef<string | null>(null);
const resumedOrderIdRef = useRef<string | null>(null);
const catalogKey = [
catalog,
characterId ?? "",
initialCategory ?? "",
initialPlanId ?? "",
commercialOfferId ?? "",
chatActionId ?? "",
].join(":");
usePendingPaymentOrderLifecycle({
@@ -73,17 +79,25 @@ export function usePaymentRouteFlow({
...(initialCategory ? { category: initialCategory } : {}),
...(initialPlanId ? { planId: initialPlanId } : {}),
...(commercialOfferId ? { commercialOfferId } : {}),
...(chatActionId ? { chatActionId } : {}),
});
}, [
catalog,
catalogKey,
characterId,
commercialOfferId,
chatActionId,
initialCategory,
initialPlanId,
initialPayChannel,
paymentDispatch,
]);
useEffect(() => {
if (!resumeOrderId || resumedOrderIdRef.current === resumeOrderId) return;
resumedOrderIdRef.current = resumeOrderId;
paymentDispatch({ type: "PaymentReturned", orderId: resumeOrderId });
}, [paymentDispatch, resumeOrderId]);
return { payment, paymentDispatch };
}
@@ -23,6 +23,7 @@ export default async function CharacterTipPage({
const initialPlanId = normalizeTipGiftParam(
getFirstPaymentSearchParam(query[TIP_GIFT_PLAN_ID_PARAM]),
);
const chatActionId = getFirstPaymentSearchParam(query.chatActionId);
return (
<TipScreen
@@ -30,6 +31,7 @@ export default async function CharacterTipPage({
initialPlanId={initialPlanId}
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
initialPayChannel={paymentReturn.initialPayChannel}
chatActionId={chatActionId}
/>
);
}
@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from "vitest";
import type { ChatAction } from "@/data/schemas/chat";
import type { PaymentOrderStatusResponse } from "@/data/schemas/payment";
import {
resolveChatActionTarget,
type ChatActionNavigationContext,
} from "../chat-action-navigation";
const baseAction: ChatAction = {
actionId: "action-1",
kind: "support",
type: "openProfile",
copy: "Open it here.",
ctaLabel: "Open",
ruleId: null,
orderId: null,
};
function createContext(
order: Partial<PaymentOrderStatusResponse> = {},
): ChatActionNavigationContext {
const orderResponse: PaymentOrderStatusResponse = {
orderId: "order-1",
status: "pending",
orderType: "dol",
planId: "topup-100",
creditsAdded: 0,
...order,
};
return {
characterRoutes: {
splash: "/characters/elio/splash",
chat: "/characters/elio/chat",
call: "/characters/elio/call",
privateZone: "/characters/elio/private-zone",
tip: "/characters/elio/tip",
},
characterSlug: "elio",
profileUrl: "/profile?returnTo=chat",
feedbackUrl: "/feedback?returnTo=chat",
coinsRulesUrl: "/coins-rules?returnTo=chat",
getOrderStatus: vi.fn(async () => orderResponse),
};
}
describe("resolveChatActionTarget", () => {
it.each([
["giftOffer", "/characters/elio/tip?chatActionId=action-1"],
["privateAlbumOffer", "/characters/elio/private-zone"],
["openProfile", "/profile?returnTo=chat"],
["openWallet", "/profile?returnTo=chat"],
["openCoinsRules", "/coins-rules?returnTo=chat"],
["openFeedback", "/feedback?returnTo=chat"],
] as const)("maps %s to an allowlisted internal page", async (type, expected) => {
await expect(
resolveChatActionTarget({ ...baseAction, type }, createContext()),
).resolves.toBe(expected);
});
it("maps VIP and top-up actions to typed subscription routes", async () => {
await expect(
resolveChatActionTarget(
{ ...baseAction, kind: "commercial", type: "activateVip" },
createContext(),
),
).resolves.toBe(
"/subscription?type=vip&returnTo=chat&character=elio&chatActionId=action-1",
);
await expect(
resolveChatActionTarget(
{ ...baseAction, kind: "commercial", type: "topUp" },
createContext(),
),
).resolves.toBe(
"/subscription?type=topup&returnTo=chat&character=elio&chatActionId=action-1",
);
});
it("resumes a pending VIP order using its owned order id", async () => {
const action: ChatAction = {
...baseAction,
type: "resumePayment",
orderId: "order-1",
};
await expect(
resolveChatActionTarget(
action,
createContext({ orderType: "vip_monthly" }),
),
).resolves.toBe(
"/subscription?type=vip&returnTo=chat&character=elio&resumeOrderId=order-1",
);
});
it("does not resume an order that is no longer pending", async () => {
const action: ChatAction = {
...baseAction,
type: "resumePayment",
orderId: "order-1",
};
await expect(
resolveChatActionTarget(action, createContext({ status: "paid" })),
).resolves.toBe("/profile?returnTo=chat");
await expect(
resolveChatActionTarget(action, createContext({ status: "expired" })),
).resolves.toBe("/feedback?returnTo=chat");
});
});
+64
View File
@@ -0,0 +1,64 @@
import type { ChatAction } from "@/data/schemas/chat";
import type { PaymentOrderStatusResponse } from "@/data/schemas/payment";
import type { CharacterRoutes } from "@/router/routes";
import { appendRouteSearchParams, ROUTE_BUILDERS } from "@/router/routes";
export interface ChatActionNavigationContext {
characterRoutes: CharacterRoutes;
characterSlug: string;
profileUrl: string;
feedbackUrl: string;
coinsRulesUrl: string;
getOrderStatus: (orderId: string) => Promise<PaymentOrderStatusResponse>;
}
/** Resolve only application-owned, allowlisted destinations for a chat action. */
export async function resolveChatActionTarget(
action: ChatAction,
context: ChatActionNavigationContext,
): Promise<string> {
switch (action.type) {
case "giftOffer":
return appendRouteSearchParams(context.characterRoutes.tip, {
chatActionId: action.actionId,
});
case "privateAlbumOffer":
return context.characterRoutes.privateZone;
case "openProfile":
case "openWallet":
return context.profileUrl;
case "openCoinsRules":
return context.coinsRulesUrl;
case "activateVip":
return ROUTE_BUILDERS.subscription("vip", {
sourceCharacterSlug: context.characterSlug,
returnTo: "chat",
chatActionId: action.actionId,
});
case "topUp":
return ROUTE_BUILDERS.subscription("topup", {
sourceCharacterSlug: context.characterSlug,
returnTo: "chat",
chatActionId: action.actionId,
});
case "openFeedback":
return context.feedbackUrl;
case "resumePayment": {
if (!action.orderId) return context.feedbackUrl;
const order = await context.getOrderStatus(action.orderId);
if (order.status === "paid") return context.profileUrl;
if (order.status !== "pending") return context.feedbackUrl;
if (order.orderType === "tip") return context.characterRoutes.tip;
return ROUTE_BUILDERS.subscription(
order.orderType === "vip" || order.orderType.startsWith("vip_")
? "vip"
: "topup",
{
sourceCharacterSlug: context.characterSlug,
returnTo: "chat",
resumeOrderId: action.orderId,
},
);
}
}
}
+80 -1
View File
@@ -18,9 +18,13 @@ import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session
import { buildGlobalPageUrl } from "@/router/global-route-context";
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
import { getCharacterRoutes, ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import type { CommercialAction } from "@/data/schemas/chat";
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
import { paymentApi } from "@/data/services/api";
import { behaviorAnalytics } from "@/lib/analytics";
import {
recordChatActionEvent,
rememberChatActionArrival,
} from "@/lib/chat/chat_action_events";
import { MobileShell } from "@/app/_components/core";
@@ -48,6 +52,7 @@ import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync";
import { resolveChatActionTarget } from "./chat-action-navigation";
const chatShellStyle = {
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
@@ -65,6 +70,14 @@ export function ChatScreen() {
ROUTES.profile,
characterRoutes.chat,
);
const feedbackUrl = buildGlobalPageUrl(
ROUTES.feedback,
characterRoutes.chat,
);
const coinsRulesUrl = buildGlobalPageUrl(
ROUTES.coinsRules,
characterRoutes.chat,
);
const searchParams = useSearchParams();
const state = useChatState();
const chatDispatch = useChatDispatch();
@@ -325,6 +338,69 @@ export function ChatScreen() {
);
}
function handleChatActionViewed(action: ChatAction): void {
void recordChatActionEvent(action, state.characterId, "viewed").catch(
() => undefined,
);
}
async function handleChatAction(action: ChatAction): Promise<void> {
behaviorAnalytics.elementClick(
"chat.action.open",
action.ctaLabel,
{
actionId: action.actionId,
actionKind: action.kind,
actionType: action.type,
characterId: state.characterId,
ruleId: action.ruleId,
},
);
await recordChatActionEvent(
action,
state.characterId,
"opened",
).catch(() => undefined);
const targetUrl = await resolveChatActionTarget(action, {
characterRoutes,
characterSlug: character.slug,
profileUrl,
feedbackUrl,
coinsRulesUrl,
getOrderStatus: (orderId) => paymentApi.getOrderStatus(orderId),
});
const authenticatedTarget = resolveAuthenticatedNavigation({
loginStatus: authState.loginStatus,
targetUrl,
});
rememberChatActionArrival(
action,
state.characterId,
targetUrl,
);
router.push(authenticatedTarget);
}
function handleChatActionDismiss(action: ChatAction): void {
behaviorAnalytics.elementClick(
"chat.action.dismiss",
"Dismiss chat action",
{
actionId: action.actionId,
actionKind: action.kind,
actionType: action.type,
characterId: state.characterId,
ruleId: action.ruleId,
},
);
void recordChatActionEvent(
action,
state.characterId,
"dismissed",
).catch(() => undefined);
}
return (
<MobileShell>
<div
@@ -375,6 +451,9 @@ export function ChatScreen() {
onCharacterAvatarClick={handleOpenCharacterPrivateZone}
onCommercialAction={handleCommercialAction}
onCommercialActionDismiss={handleCommercialActionDismiss}
onChatActionViewed={handleChatActionViewed}
onChatAction={handleChatAction}
onChatActionDismiss={handleChatActionDismiss}
onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
}}
@@ -0,0 +1,67 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatActionCard } from "../chat-action-card";
describe("ChatActionCard", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("reports one view, opens the action and can be dismissed", async () => {
const onViewed = vi.fn();
const onActivate = vi.fn(async () => undefined);
const onDismiss = vi.fn();
const action = {
actionId: "action-1",
kind: "support" as const,
type: "openFeedback" as const,
copy: "Share the screenshot and approximate payment time here.",
ctaLabel: "Open Feedback",
ruleId: null,
orderId: null,
};
await act(async () => {
root.render(
<ChatActionCard
action={action}
onViewed={onViewed}
onActivate={onActivate}
onDismiss={onDismiss}
/>,
);
});
expect(onViewed).toHaveBeenCalledTimes(1);
expect(container.querySelector("[data-chat-action='openFeedback']"))
.not.toBeNull();
const openButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Open Feedback"),
);
await act(async () => openButton?.click());
expect(onActivate).toHaveBeenCalledWith(action);
const dismissButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Dismiss"]',
);
act(() => dismissButton?.click());
expect(onDismiss).toHaveBeenCalledWith(action);
expect(container.innerHTML).toBe("");
});
});
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ArrowRight,
CircleDollarSign,
CircleHelp,
Gift,
Images,
LoaderCircle,
MessageSquareWarning,
UserRound,
WalletCards,
X,
type LucideIcon,
} from "lucide-react";
import type { ChatAction } from "@/data/schemas/chat";
import styles from "./chat-area.module.css";
const ACTION_ICONS: Record<ChatAction["type"], LucideIcon> = {
giftOffer: Gift,
privateAlbumOffer: Images,
openProfile: UserRound,
openWallet: WalletCards,
openCoinsRules: WalletCards,
activateVip: CircleDollarSign,
topUp: CircleDollarSign,
openFeedback: MessageSquareWarning,
resumePayment: CircleHelp,
};
export interface ChatActionCardProps {
action: ChatAction;
onViewed?: (action: ChatAction) => void;
onActivate?: (action: ChatAction) => void | Promise<void>;
onDismiss?: (action: ChatAction) => void;
}
export function ChatActionCard({
action,
onViewed,
onActivate,
onDismiss,
}: ChatActionCardProps) {
const [dismissed, setDismissed] = useState(false);
const [activating, setActivating] = useState(false);
const [activationError, setActivationError] = useState<string | null>(null);
const viewedActionIdRef = useRef<string | null>(null);
useEffect(() => {
if (viewedActionIdRef.current === action.actionId) return;
viewedActionIdRef.current = action.actionId;
onViewed?.(action);
}, [action, onViewed]);
if (dismissed) return null;
const Icon = ACTION_ICONS[action.type];
return (
<aside
className={styles.commercialAction}
aria-label={action.ctaLabel}
data-chat-action={action.type}
data-chat-action-kind={action.kind}
>
<div className={styles.commercialActionHeader}>
<Icon size={18} aria-hidden="true" />
<button
type="button"
className={styles.commercialActionDismiss}
title="Dismiss"
aria-label="Dismiss"
onClick={() => {
setDismissed(true);
onDismiss?.(action);
}}
>
<X size={16} aria-hidden="true" />
</button>
</div>
<p className={styles.commercialActionCopy}>{action.copy}</p>
{activationError ? (
<p className={styles.commercialActionError} role="alert">
{activationError}
</p>
) : null}
<button
type="button"
className={styles.commercialActionButton}
disabled={activating}
onClick={() => {
setActivating(true);
setActivationError(null);
void Promise.resolve(onActivate?.(action))
.catch(() =>
setActivationError(
"This option is unavailable right now. Please try again.",
),
)
.finally(() => setActivating(false));
}}
>
<span>{action.ctaLabel}</span>
{activating ? (
<LoaderCircle
className={styles.commercialActionSpinner}
size={17}
aria-hidden="true"
/>
) : (
<ArrowRight size={17} aria-hidden="true" />
)}
</button>
</aside>
);
}
+17 -1
View File
@@ -24,7 +24,7 @@ import {
import { LoaderCircle } from "lucide-react";
import type { UiMessage } from "@/stores/chat/ui-message";
import type { CommercialAction } from "@/data/schemas/chat";
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import {
@@ -64,6 +64,9 @@ export interface ChatAreaProps {
onCharacterAvatarClick?: () => void;
onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (action: CommercialAction) => void;
onChatActionViewed?: (action: ChatAction) => void;
onChatAction?: (action: ChatAction) => void | Promise<void>;
onChatActionDismiss?: (action: ChatAction) => void;
}
type ChatMessageAction = (
@@ -90,6 +93,9 @@ export function ChatArea({
onCharacterAvatarClick,
onCommercialAction,
onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -264,6 +270,9 @@ export function ChatArea({
onCharacterAvatarClick,
onCommercialAction,
onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
)}
{isReplyingAI ? (
@@ -304,6 +313,9 @@ function renderMessagesWithDateHeaders(
onCharacterAvatarClick?: () => void,
onCommercialAction?: (action: CommercialAction) => void,
onCommercialActionDismiss?: (action: CommercialAction) => void,
onChatActionViewed?: (action: ChatAction) => void,
onChatAction?: (action: ChatAction) => void | Promise<void>,
onChatActionDismiss?: (action: ChatAction) => void,
) {
return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? (
@@ -324,6 +336,7 @@ function renderMessagesWithDateHeaders(
lockedPrivate={item.message.lockedPrivate}
privateMessageHint={item.message.privateMessageHint}
commercialAction={item.message.commercialAction}
chatAction={item.message.chatAction}
isUnlockingMessage={
isUnlockingMessage === true &&
item.message.displayId === unlockingMessageId
@@ -336,6 +349,9 @@ function renderMessagesWithDateHeaders(
onCharacterAvatarClick={onCharacterAvatarClick}
onCommercialAction={onCommercialAction}
onCommercialActionDismiss={onCommercialActionDismiss}
onChatActionViewed={onChatActionViewed}
onChatAction={onChatAction}
onChatActionDismiss={onChatActionDismiss}
/>
),
);
+1
View File
@@ -6,6 +6,7 @@ export * from "./ai-disclosure-banner";
export * from "./browser-hint-overlay";
export * from "./chat-area";
export * from "./commercial-action-card";
export * from "./chat-action-card";
export * from "./chat-header";
export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar";
+13 -1
View File
@@ -9,7 +9,7 @@
* - 用户:[占位 spacer] [Content] [Avatar]
*/
import { useUserSelector } from "@/stores/user/user-context";
import type { CommercialAction } from "@/data/schemas/chat";
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
import { MessageAvatar } from "./message-avatar";
import { MessageContent } from "./message-content";
@@ -29,6 +29,7 @@ export interface MessageBubbleProps {
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
commercialAction?: CommercialAction | null;
chatAction?: ChatAction | null;
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction;
@@ -38,6 +39,9 @@ export interface MessageBubbleProps {
onCharacterAvatarClick?: () => void;
onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (action: CommercialAction) => void;
onChatActionViewed?: (action: ChatAction) => void;
onChatAction?: (action: ChatAction) => void | Promise<void>;
onChatActionDismiss?: (action: ChatAction) => void;
}
type ChatMessageAction = (
@@ -59,6 +63,7 @@ export function MessageBubble({
lockedPrivate,
privateMessageHint,
commercialAction,
chatAction,
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
@@ -68,6 +73,9 @@ export function MessageBubble({
onCharacterAvatarClick,
onCommercialAction,
onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
}: MessageBubbleProps) {
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
@@ -97,6 +105,7 @@ export function MessageBubble({
lockedPrivate={lockedPrivate}
privateMessageHint={privateMessageHint}
commercialAction={commercialAction}
chatAction={chatAction}
isUnlockingMessage={isUnlockingMessage}
onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage}
@@ -104,6 +113,9 @@ export function MessageBubble({
onOpenImage={onOpenImage}
onCommercialAction={onCommercialAction}
onCommercialActionDismiss={onCommercialActionDismiss}
onChatActionViewed={onChatActionViewed}
onChatAction={onChatAction}
onChatActionDismiss={onChatActionDismiss}
/>
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div>
+18 -2
View File
@@ -1,6 +1,7 @@
"use client";
import type { CommercialAction } from "@/data/schemas/chat";
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
import { ChatActionCard } from "./chat-action-card";
import { CommercialActionCard } from "./commercial-action-card";
import { ImageBubble } from "./image-bubble";
import { LockedImageMessageCard } from "./locked-image-message-card";
@@ -23,6 +24,7 @@ export interface MessageContentProps {
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
commercialAction?: CommercialAction | null;
chatAction?: ChatAction | null;
isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction;
onUnlockVoiceMessage?: ChatMessageAction;
@@ -30,6 +32,9 @@ export interface MessageContentProps {
onOpenImage?: (displayMessageId: string) => void;
onCommercialAction?: (action: CommercialAction) => void;
onCommercialActionDismiss?: (action: CommercialAction) => void;
onChatActionViewed?: (action: ChatAction) => void;
onChatAction?: (action: ChatAction) => void | Promise<void>;
onChatActionDismiss?: (action: ChatAction) => void;
}
type ChatMessageAction = (
@@ -53,6 +58,7 @@ export function MessageContent({
lockedPrivate,
privateMessageHint,
commercialAction,
chatAction,
isUnlockingMessage,
onUnlockPrivateMessage,
onUnlockVoiceMessage,
@@ -60,6 +66,9 @@ export function MessageContent({
onOpenImage,
onCommercialAction,
onCommercialActionDismiss,
onChatActionViewed,
onChatAction,
onChatActionDismiss,
}: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0;
const hasAudio = audioUrl != null && audioUrl.length > 0;
@@ -134,7 +143,14 @@ export function MessageContent({
) : null}
</>
)}
{isFromAI && commercialAction ? (
{isFromAI && chatAction ? (
<ChatActionCard
action={chatAction}
onViewed={onChatActionViewed}
onActivate={onChatAction}
onDismiss={onChatActionDismiss}
/>
) : isFromAI && commercialAction ? (
<CommercialActionCard
action={commercialAction}
onActivate={onCommercialAction}
@@ -14,9 +14,20 @@ vi.mock("@/stores/auth/auth-context", () => ({
vi.mock("@/stores/user/user-context", () => ({
useUserDispatch: () => vi.fn(),
useUserState: () => ({
currentUser: {
dailyFreeChatLimit: 30,
dailyFreePrivateLimit: 2,
currentUser: null,
entitlements: {
quotas: {
normalChatFreeDaily: 47,
privateUnlockFreeDaily: 5,
},
costs: {
normal_message: 3,
private_message: 9,
voice_message: 11,
photo: 13,
private_album_10: 101,
private_album_20: 181,
},
},
}),
}));
@@ -31,5 +42,11 @@ describe("CoinsRulesScreen", () => {
expect(html).toContain(
'href="/profile?returnTo=%2Fcharacters%2Fmaya%2Fchat"',
);
expect(html).toContain("47 free messages daily");
expect(html).toContain("5 free private messages daily");
expect(html).toContain("3 coins / message");
expect(html).toContain("10 photos / 101 coins");
expect(html).not.toContain("30 free messages daily");
expect(html).not.toContain("2 free private messages daily");
});
});
+58 -22
View File
@@ -19,10 +19,6 @@ import {
import { useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import {
FREE_PRIVATE_MESSAGE_DAILY_COUNT,
FREE_STANDARD_CHAT_DAILY_COUNT,
} from "./coins-rules.constants";
import styles from "./coins-rules-screen.module.css";
interface CoinRuleItem {
@@ -35,25 +31,44 @@ interface CoinRuleItem {
const formatDailyFreeMessages = (count: number, label: string): string =>
`${Math.max(0, Math.trunc(count))} free ${label} daily`;
const formatCoinCost = (cost: number | null, unit: string): string =>
cost === null
? "Current rate shown at unlock"
: `${Math.max(0, Math.trunc(cost))} coins / ${unit}`;
const createCoinRules = (
dailyFreeChatLimit: number,
dailyFreePrivateLimit: number,
dailyFreeChatLimit: number | null,
dailyFreePrivateLimit: number | null,
costs: {
normalMessage: number | null;
privateMessage: number | null;
voiceMessage: number | null;
photo: number | null;
privateAlbum10: number | null;
privateAlbum20: number | null;
},
): readonly CoinRuleItem[] => [
{
title: "Standard Chat",
cost: "2 coins / message",
free: formatDailyFreeMessages(dailyFreeChatLimit, "messages"),
cost: formatCoinCost(costs.normalMessage, "message"),
free:
dailyFreeChatLimit === null
? "Current daily allowance loads from your account"
: formatDailyFreeMessages(dailyFreeChatLimit, "messages"),
icon: MessageCircle,
},
{
title: "Private Chat",
cost: "10 coins / message",
free: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"),
cost: formatCoinCost(costs.privateMessage, "message"),
free:
dailyFreePrivateLimit === null
? "Current daily allowance loads from your account"
: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages"),
icon: Sparkles,
},
{
title: "Voice Message",
cost: "20 coins / message",
cost: formatCoinCost(costs.voiceMessage, "message"),
icon: Mic2,
},
// {
@@ -63,17 +78,23 @@ const createCoinRules = (
// },
{
title: "Photo",
cost: "40 coins / photo",
cost: formatCoinCost(costs.photo, "photo"),
icon: ImageIcon,
},
{
title: "Private Album",
cost: "10 photos / 300 coins",
cost:
costs.privateAlbum10 === null
? "Current price shown in Private Zone"
: `10 photos / ${Math.max(0, Math.trunc(costs.privateAlbum10))} coins`,
icon: ImageIcon,
},
{
title: "Private Album",
cost: "20 photos / 600 coins",
cost:
costs.privateAlbum20 === null
? "Current price shown in Private Zone"
: `20 photos / ${Math.max(0, Math.trunc(costs.privateAlbum20))} coins`,
icon: ImageIcon,
},
] as const;
@@ -87,20 +108,35 @@ export function CoinsRulesScreen({ returnTo }: CoinsRulesScreenProps) {
const authState = useAuthState();
const userState = useUserState();
const userDispatch = useUserDispatch();
const entitlements = userState.entitlements;
const dailyFreeChatLimit =
userState.currentUser?.dailyFreeChatLimit ?? FREE_STANDARD_CHAT_DAILY_COUNT;
entitlements?.quotas.normalChatFreeDaily ??
userState.currentUser?.dailyFreeChatLimit ??
null;
const dailyFreePrivateLimit =
entitlements?.quotas.privateUnlockFreeDaily ??
userState.currentUser?.dailyFreePrivateLimit ??
FREE_PRIVATE_MESSAGE_DAILY_COUNT;
const standardChatLabel = formatDailyFreeMessages(
null;
const standardChatLabel =
dailyFreeChatLimit === null
? "the current daily allowance shown for your account"
: formatDailyFreeMessages(dailyFreeChatLimit, "messages");
const privateChatLabel =
dailyFreePrivateLimit === null
? "the current private allowance shown for your account"
: formatDailyFreeMessages(dailyFreePrivateLimit, "private messages");
const coinRules = createCoinRules(
dailyFreeChatLimit,
"messages",
);
const privateChatLabel = formatDailyFreeMessages(
dailyFreePrivateLimit,
"private messages",
{
normalMessage: entitlements?.costs.normal_message ?? null,
privateMessage: entitlements?.costs.private_message ?? null,
voiceMessage: entitlements?.costs.voice_message ?? null,
photo: entitlements?.costs.photo ?? null,
privateAlbum10: entitlements?.costs.private_album_10 ?? null,
privateAlbum20: entitlements?.costs.private_album_20 ?? null,
},
);
const coinRules = createCoinRules(dailyFreeChatLimit, dailyFreePrivateLimit);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
@@ -1,2 +0,0 @@
export const FREE_STANDARD_CHAT_DAILY_COUNT = 30;
export const FREE_PRIVATE_MESSAGE_DAILY_COUNT = 2;
+4
View File
@@ -36,6 +36,8 @@ export default async function SubscriptionPage({
const commercialOfferId = getFirstPaymentSearchParam(
query.commercialOfferId,
);
const resumeOrderId = getFirstPaymentSearchParam(query.resumeOrderId);
const chatActionId = getFirstPaymentSearchParam(query.chatActionId);
const analyticsContext = parsePaymentAnalyticsContext({
entryPoint: getFirstPaymentSearchParam(
query[PAYMENT_ANALYTICS_ENTRY_PARAM],
@@ -56,6 +58,8 @@ export default async function SubscriptionPage({
sourceCharacterSlug={sourceCharacterSlug}
initialPlanId={initialPlanId}
commercialOfferId={commercialOfferId}
resumeOrderId={resumeOrderId}
chatActionId={chatActionId}
/>
);
}
@@ -47,6 +47,8 @@ export interface SubscriptionScreenProps {
sourceCharacterSlug?: string;
initialPlanId?: string | null;
commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
}
export function SubscriptionScreen({
@@ -58,6 +60,8 @@ export function SubscriptionScreen({
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
initialPlanId = null,
commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
}: SubscriptionScreenProps) {
const userState = useUserState();
const hasHydrated = useHasHydrated();
@@ -83,6 +87,8 @@ export function SubscriptionScreen({
initialPayChannel: paymentMethodConfig.initialPayChannel,
initialPlanId,
commercialOfferId,
resumeOrderId,
chatActionId,
});
const canSubscribeVip = subscriptionType === "vip";
const analyticsContext =
@@ -22,6 +22,8 @@ export interface UseSubscriptionPaymentFlowInput {
sourceCharacterSlug?: string;
initialPlanId?: string | null;
commercialOfferId?: string | null;
resumeOrderId?: string | null;
chatActionId?: string | null;
}
export function useSubscriptionPaymentFlow({
@@ -32,6 +34,8 @@ export function useSubscriptionPaymentFlow({
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
initialPlanId = null,
commercialOfferId = null,
resumeOrderId = null,
chatActionId = null,
}: UseSubscriptionPaymentFlowInput) {
const router = useRouter();
const { payment, paymentDispatch } = usePaymentRouteFlow({
@@ -40,6 +44,8 @@ export function useSubscriptionPaymentFlow({
shouldResumePendingOrder,
initialPlanId,
commercialOfferId,
resumeOrderId,
chatActionId,
});
const successDialogShownOrderRef = useRef<string | null>(null);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
+3
View File
@@ -40,6 +40,7 @@ export interface TipScreenProps {
initialPlanId?: string | null;
shouldResumePendingOrder?: boolean;
initialPayChannel?: PayChannel | null;
chatActionId?: string | null;
}
export function TipScreen({
@@ -47,6 +48,7 @@ export function TipScreen({
initialPlanId = null,
shouldResumePendingOrder = false,
initialPayChannel = null,
chatActionId = null,
}: TipScreenProps) {
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
@@ -68,6 +70,7 @@ export function TipScreen({
initialPayChannel: paymentMethodConfig.initialPayChannel,
paymentType: "tip",
shouldResumePendingOrder,
chatActionId,
});
const selectedCategory =