Files
cozsweet-frontend-nextjs/src/app/chat/hooks/use-chat-unlock-coordinator.ts
T
Codex 8c0d1ad3ce
Docker Image / Build and Push Docker Image (push) Successful in 5m2s
feat(chat): render role-bound payment guidance
2026-07-27 18:21:13 +08:00

446 lines
13 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { shallowEqual } from "@xstate/react";
import type { ChatLockType } from "@/data/schemas/chat";
import { behaviorAnalytics } from "@/lib/analytics";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
type PendingChatPromotion,
type PendingChatUnlock,
type PendingChatUnlockKind,
} from "@/lib/navigation/chat_unlock_session";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
useChatDispatch,
useChatSelector,
} from "@/stores/chat/chat-context";
import type { ChatPromotionState } from "@/stores/chat/helper/promotion";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import type { UiMessage } from "@/stores/chat/ui-message";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useUserSelector } from "@/stores/user/user-context";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
export interface ChatUnlockCoordinatorScope {
defaultReturnUrl: string;
imageMessageId: string | null;
imageReturnUrl: string;
promotion: ChatPromotionState | null;
}
export interface CoordinatedMessageUnlockRequest {
displayMessageId: string;
messageId?: string;
kind: PendingChatUnlockKind;
lockType?: ChatLockType;
clientLockId?: string;
promotion?: PendingChatPromotion;
returnUrl: string;
}
export interface ChatUnlockDialogModel {
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
historyUnlock: {
open: boolean;
lockedCount: number;
isLoading: boolean;
errorMessage: string | null;
guidance: import("@/data/schemas/chat").PaymentGuidance | null;
};
closeHistoryUnlock: () => void;
confirmHistoryUnlock: () => void;
closePaywall: () => void;
confirmPaywall: () => void;
}
export interface UseChatUnlockCoordinatorInput
extends ChatUnlockCoordinatorScope {
enabled?: boolean;
}
export interface UseChatUnlockCoordinatorOutput {
requestMessageUnlock: (input: {
displayMessageId: string;
remoteMessageId?: string;
kind: PendingChatUnlockKind;
}) => void;
dialogs: ChatUnlockDialogModel;
}
export function useChatUnlockCoordinator({
defaultReturnUrl,
imageMessageId,
imageReturnUrl,
promotion,
enabled = true,
}: UseChatUnlockCoordinatorInput): UseChatUnlockCoordinatorOutput {
const navigator = useAppNavigator();
const isVip = useUserSelector((state) => state.context.isVip);
const trackedPaywallRef = useRef<string | null>(null);
const chatState = useChatSelector(
(state) => ({
characterId: state.context.characterId,
historyLoaded: state.context.historyLoaded,
messages: state.context.messages,
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
lockedHistoryCount: state.context.lockedHistoryCount,
unlockHistoryError: state.context.unlockHistoryError,
unlockHistoryPaymentGuidance:
state.context.unlockHistoryPaymentGuidance,
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
unlockPaywallRequest: state.context.unlockPaywallRequest,
}),
shallowEqual,
);
const chatDispatch = useChatDispatch();
const unlockPaywallRequest = enabled
? chatState.unlockPaywallRequest
: null;
useEffect(() => {
const guidance = chatState.unlockHistoryPaymentGuidance;
if (!enabled || guidance?.characterId !== chatState.characterId) return;
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"viewed",
).catch(() => undefined);
}, [
chatState.characterId,
chatState.unlockHistoryPaymentGuidance,
enabled,
]);
useEffect(() => {
if (!unlockPaywallRequest) {
trackedPaywallRef.current = null;
return;
}
const requestKey = [
unlockPaywallRequest.displayMessageId,
unlockPaywallRequest.messageId ?? "",
unlockPaywallRequest.kind,
unlockPaywallRequest.clientLockId ?? "",
].join(":");
if (trackedPaywallRef.current === requestKey) return;
trackedPaywallRef.current = requestKey;
behaviorAnalytics.paywallShown(
{
entryPoint: "chat_unlock",
triggerReason: unlockPaywallRequest.promotion
? "ad_landing"
: "insufficient_credits",
},
{ isVip },
);
const guidance = unlockPaywallRequest.paymentGuidance;
if (guidance?.characterId === chatState.characterId) {
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"viewed",
).catch(() => undefined);
}
}, [chatState.characterId, isVip, unlockPaywallRequest]);
useEffect(() => {
if (!enabled) return;
if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
let cancelled = false;
const resumePendingUnlock = async () => {
const pending = await peekPendingChatUnlock(chatState.characterId);
if (
cancelled ||
!pending ||
!shouldResumePendingChatUnlock(pending, {
defaultReturnUrl,
imageMessageId,
imageReturnUrl,
})
) {
return;
}
const consumed = await consumePendingChatUnlock(chatState.characterId);
if (cancelled || !consumed) return;
const displayMessageId = resolvePendingUnlockDisplayMessageId(
consumed,
chatState.messages,
promotion,
);
chatDispatch({
type: "ChatUnlockMessageRequested",
displayMessageId,
remoteMessageId: consumed.messageId,
kind: consumed.kind,
lockType: consumed.lockType,
clientLockId: consumed.clientLockId,
});
};
void resumePendingUnlock();
return () => {
cancelled = true;
};
}, [
chatDispatch,
chatState.characterId,
chatState.historyLoaded,
defaultReturnUrl,
enabled,
imageMessageId,
imageReturnUrl,
navigator.isAuthenticatedUser,
promotion,
chatState.messages,
]);
function requestMessageUnlock(input: {
displayMessageId: string;
remoteMessageId?: string;
kind: PendingChatUnlockKind;
}): void {
const request = resolveMessageUnlockRequest(
input,
{
defaultReturnUrl,
imageMessageId,
imageReturnUrl,
promotion,
},
);
navigator.startMessageUnlock({
...request,
onAuthenticated: () => {
chatDispatch({
type: "ChatUnlockMessageRequested",
displayMessageId: request.displayMessageId,
remoteMessageId: request.messageId,
kind: request.kind,
lockType: request.lockType,
clientLockId: request.clientLockId,
});
},
});
}
function closeHistoryUnlock(): void {
chatDispatch({ type: "ChatUnlockHistoryDismissed" });
}
function confirmHistoryUnlock(): void {
const guidance =
chatState.unlockHistoryPaymentGuidance?.characterId ===
chatState.characterId
? chatState.unlockHistoryPaymentGuidance
: null;
if (guidance) {
if (guidance.mode !== "guide") return;
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"opened",
).catch(() => undefined);
navigator.openSubscription({
type:
isVip || !guidance.purchaseOptions.includes("vip")
? "topup"
: "vip",
returnTo: "chat",
chatActionId: guidance.guidanceId,
analytics: {
entryPoint: "chat_unlock",
triggerReason: "insufficient_credits",
},
});
return;
}
chatDispatch({ type: "ChatUnlockHistoryConfirmed" });
}
function closePaywall(): void {
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
}
function confirmPaywall(): void {
if (!unlockPaywallRequest) return;
const guidance =
unlockPaywallRequest.paymentGuidance?.characterId ===
chatState.characterId
? unlockPaywallRequest.paymentGuidance
: null;
if (guidance && guidance.mode !== "guide") return;
const returnUrl = resolveChatUnlockReturnUrl(
unlockPaywallRequest,
{
defaultReturnUrl,
imageMessageId,
imageReturnUrl,
},
);
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
chatState.characterId,
"opened",
).catch(() => undefined);
}
navigator.openSubscriptionForPendingUnlock({
displayMessageId: unlockPaywallRequest.displayMessageId,
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
lockType: unlockPaywallRequest.lockType,
clientLockId: unlockPaywallRequest.clientLockId,
promotion: unlockPaywallRequest.promotion,
returnUrl,
type:
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
? "topup"
: getInsufficientCreditsSubscriptionType(isVip),
analytics: {
entryPoint: "chat_unlock",
triggerReason: unlockPaywallRequest.promotion
? "ad_landing"
: "insufficient_credits",
},
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
});
}
return {
requestMessageUnlock,
dialogs: {
unlockPaywallRequest,
historyUnlock: {
open: chatState.unlockHistoryPromptVisible,
lockedCount: chatState.lockedHistoryCount,
isLoading: chatState.isUnlockingHistory,
errorMessage: chatState.unlockHistoryError,
guidance: chatState.unlockHistoryPaymentGuidance,
},
closeHistoryUnlock,
confirmHistoryUnlock,
closePaywall,
confirmPaywall,
},
};
}
export function resolveMessageUnlockRequest(
input: {
displayMessageId: string;
remoteMessageId?: string;
kind: PendingChatUnlockKind;
},
scope: ChatUnlockCoordinatorScope,
): CoordinatedMessageUnlockRequest {
const matchedPromotion =
scope.promotion?.message.displayId === input.displayMessageId
? scope.promotion
: null;
const remoteMessageId =
input.remoteMessageId ?? matchedPromotion?.message.remoteId;
const target = {
displayMessageId: input.displayMessageId,
...(remoteMessageId ? { messageId: remoteMessageId } : {}),
kind: input.kind,
...(matchedPromotion
? {
lockType: matchedPromotion.session.lockType,
clientLockId: matchedPromotion.session.clientLockId,
promotion: matchedPromotion.session,
}
: {}),
};
return {
...target,
returnUrl: resolveChatUnlockReturnUrl(target, scope),
};
}
export function shouldResumePendingChatUnlock(
pending: PendingChatUnlock,
scope: Omit<ChatUnlockCoordinatorScope, "promotion">,
): boolean {
if (pending.returnUrl === scope.defaultReturnUrl) return true;
if (pending.returnUrl !== scope.imageReturnUrl) return false;
return isCurrentImageUnlock(pending, scope.imageMessageId);
}
export function resolvePendingUnlockDisplayMessageId(
pending: PendingChatUnlock,
messages: readonly UiMessage[],
promotion: ChatPromotionState | null,
): string {
if (
promotion &&
(promotion.message.displayId === pending.displayMessageId ||
(pending.messageId &&
promotion.message.remoteId === pending.messageId))
) {
return promotion.message.displayId;
}
const exactMessage = messages.find(
(message) => message.displayId === pending.displayMessageId,
);
if (exactMessage) return exactMessage.displayId;
if (!pending.messageId) return pending.displayMessageId;
const remoteMessage =
messages.find(
(message) =>
message.isFromAI &&
message.locked === true &&
message.remoteId === pending.messageId,
) ??
messages.find(
(message) =>
message.isFromAI && message.remoteId === pending.messageId,
);
return remoteMessage?.displayId ?? pending.displayMessageId;
}
export function resolveChatUnlockReturnUrl(
target: Pick<
CoordinatedMessageUnlockRequest,
"displayMessageId" | "messageId" | "kind" | "promotion"
>,
scope: Pick<
ChatUnlockCoordinatorScope,
"defaultReturnUrl" | "imageMessageId" | "imageReturnUrl"
>,
): string {
return isCurrentImageUnlock(target, scope.imageMessageId)
? scope.imageReturnUrl
: scope.defaultReturnUrl;
}
function isCurrentImageUnlock(
target: Pick<
CoordinatedMessageUnlockRequest,
"displayMessageId" | "messageId" | "kind" | "promotion"
>,
imageMessageId: string | null,
): boolean {
if (target.promotion || target.kind !== "image" || !imageMessageId) {
return false;
}
return (
target.displayMessageId === imageMessageId ||
target.messageId === imageMessageId
);
}