Files
cozsweet-frontend-nextjs/src/app/chat/hooks/use-chat-message-limit-banner.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

112 lines
3.2 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { behaviorAnalytics } from "@/lib/analytics";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
import type { PaymentGuidance } from "@/data/schemas/chat";
import { recordChatActionEventById } from "@/lib/chat/chat_action_events";
import { useActiveCharacter } from "@/providers/character-provider";
import { useUserSelector } from "@/stores/user/user-context";
import {
getInsufficientCreditsMessageLimitView,
getInsufficientCreditsSubscriptionType,
shouldShowMessageLimitBanner,
type InsufficientCreditsMessageLimitView,
} from "../chat-screen.helpers";
export interface UseChatMessageLimitBannerInput {
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
paymentGuidance: PaymentGuidance | null;
}
export interface ChatMessageLimitBannerView {
title: string;
description: string;
ctaLabel: string | null;
guidance: PaymentGuidance | null;
visible: boolean;
unlock: () => void;
}
export function useChatMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
paymentGuidance,
}: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView {
const navigator = useAppNavigator();
const character = useActiveCharacter();
const isVip = useUserSelector((state) => state.context.isVip);
const fallbackView = getInsufficientCreditsMessageLimitView();
const guidance =
paymentGuidance?.characterId === character.id ? paymentGuidance : null;
const view: InsufficientCreditsMessageLimitView = {
title: guidance?.title ?? fallbackView.title,
description: guidance?.copy ?? fallbackView.description,
ctaLabel: guidance?.ctaLabel ?? fallbackView.ctaLabel,
};
const visible = shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
});
const trackedVisibleRef = useRef(false);
useEffect(() => {
if (!visible) {
trackedVisibleRef.current = false;
return;
}
if (trackedVisibleRef.current) return;
trackedVisibleRef.current = true;
behaviorAnalytics.paywallShown(
{
entryPoint: "chat_input",
triggerReason: "insufficient_credits",
},
{ isVip },
);
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"viewed",
).catch(() => undefined);
}
}, [character.id, guidance, isVip, visible]);
function unlock(): void {
if (guidance?.mode !== "guide" && guidance !== null) return;
if (guidance) {
void recordChatActionEventById(
guidance.guidanceId,
character.id,
"opened",
).catch(() => undefined);
}
navigator.openSubscription({
type:
isVip || (guidance && !guidance.purchaseOptions.includes("vip"))
? "topup"
: getInsufficientCreditsSubscriptionType(isVip),
returnTo: "chat",
...(guidance ? { chatActionId: guidance.guidanceId } : {}),
analytics: {
entryPoint: "chat_input",
triggerReason: "insufficient_credits",
},
});
}
return {
...view,
ctaLabel:
guidance && guidance.mode !== "guide" ? null : view.ctaLabel,
guidance,
visible,
unlock,
};
}