Files
cozsweet-frontend-nextjs/src/app/chat/chat-screen.tsx
T

235 lines
7.4 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
openChatInExternalBrowser,
recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser";
import { MobileShell } from "@/app/_components/core";
import {
BrowserHintOverlay,
ChatArea,
ChatHeader,
ChatInputBar,
ChatInsufficientCreditsBanner,
ExternalBrowserDialog,
FirstRechargeOfferBanner,
HistoryUnlockDialog,
InsufficientCreditsDialog,
PwaInstallOverlay,
} from "./components";
import {
deriveIsGuest,
getInsufficientCreditsSubscriptionType,
isChatDevelopmentEnvironment,
shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers";
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
import styles from "./components/chat-screen.module.css";
export function ChatScreen() {
const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState();
const authDispatch = useAuthDispatch();
const userState = useUserState();
const navigator = useAppNavigator();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
const {
unlockPaywallRequest,
requestMessageUnlock,
closeInsufficientCreditsDialog,
confirmInsufficientCreditsDialog,
} = useChatUnlockNavigationFlow({ returnUrl: ROUTES.chat });
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
// 发送能力由后端统一返回,当前只处理积分不足引导。
const showMessageLimitBanner =
state.upgradePromptVisible &&
state.upgradeReason === "insufficient_credits";
const messageLimitTitle =
"Insufficient credits\nTop up to continue chatting";
const messageLimitCtaLabel = "Top up credits to continue";
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus,
});
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
const externalBrowserPromptShownRef = useRef(false);
const guestLoginRequestedRef = useRef(false);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
if (authState.loginStatus !== "notLoggedIn") {
guestLoginRequestedRef.current = false;
return;
}
if (guestLoginRequestedRef.current) return;
guestLoginRequestedRef.current = true;
authDispatch({ type: "AuthGuestLoginSubmitted" });
}, [
authDispatch,
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
]);
useEffect(() => {
if (
!shouldStartExternalBrowserPrompt({
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
loginStatus: authState.loginStatus,
})
) {
return;
}
let mounted = true;
let timer: number | undefined;
const init = async () => {
if (externalBrowserPromptShownRef.current) return;
const eligibility = await resolveExternalBrowserPromptEligibility();
if (!mounted || !eligibility.canShow) return;
externalBrowserPromptShownRef.current = true;
timer = window.setTimeout(() => {
if (!mounted) return;
setShowExternalBrowserDialog(true);
void recordExternalBrowserPromptShown(eligibility.today);
}, 3000);
};
void init();
return () => {
mounted = false;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
]);
async function handleOpenExternalBrowser(): Promise<void> {
setShowExternalBrowserDialog(false);
await openChatInExternalBrowser();
}
function handleUnlockPrivateMessage(messageId: string): void {
requestMessageUnlock(messageId, "private");
}
function handleMessageLimitUnlock(): void {
navigator.openSubscription({
type: getInsufficientCreditsSubscriptionType(userState.isVip),
returnTo: "chat",
});
}
function handleUnlockVoiceMessage(messageId: string): void {
requestMessageUnlock(messageId, "voice");
}
return (
<MobileShell>
<div className={styles.shell}>
<div className={styles.background}>
<Image
src="/images/chat/bg-chatpage.png"
alt=""
fill
priority
sizes="(max-width: 540px) 100vw, 540px"
/>
</div>
<div className={styles.layout}>
{/* isGuest 派生自 auth.loginStatus */}
<ChatHeader
isGuest={isGuest}
offerBanner={
<FirstRechargeOfferBanner
visible={firstRechargeOfferBanner.visible}
discountPercent={firstRechargeOfferBanner.discountPercent}
onClick={firstRechargeOfferBanner.claim}
onClose={firstRechargeOfferBanner.close}
/>
}
/>
<ChatArea
messages={state.messages}
isReplyingAI={state.isReplyingAI}
isGuest={isGuest}
isUnlockingMessage={state.isUnlockingMessage}
unlockingMessageId={state.unlockingMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
onUnlockVoiceMessage={handleUnlockVoiceMessage}
/>
{showMessageLimitBanner ? (
<ChatInsufficientCreditsBanner
title={messageLimitTitle}
ctaLabel={messageLimitCtaLabel}
onUnlock={handleMessageLimitUnlock}
/>
) : (
<ChatInputBar disabled={!state.historyLoaded} />
)}
</div>
{/* 浏览器提示(应用内浏览器检测) */}
<BrowserHintOverlay forceShow={isChatDevelopmentEnvironment()} />
{/* PWA 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */}
<PwaInstallOverlay enabled={shouldShowPwaInstall} />
<ExternalBrowserDialog
open={showExternalBrowserDialog}
onClose={() => setShowExternalBrowserDialog(false)}
onConfirm={() => void handleOpenExternalBrowser()}
/>
<HistoryUnlockDialog
open={state.unlockHistoryPromptVisible}
lockedCount={state.lockedHistoryCount}
isLoading={state.isUnlockingHistory}
errorMessage={state.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
/>
<InsufficientCreditsDialog
open={unlockPaywallRequest !== null}
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={closeInsufficientCreditsDialog}
onConfirm={confirmInsufficientCreditsDialog}
/>
</div>
</MobileShell>
);
}