diff --git a/src/app/auth/components/auth-screen.tsx b/src/app/auth/components/auth-screen.tsx index 9828a01b..7ae6cdaf 100644 --- a/src/app/auth/components/auth-screen.tsx +++ b/src/app/auth/components/auth-screen.tsx @@ -24,8 +24,8 @@ export function AuthScreen() { const router = useRouter(); // 邮箱登录成功 → 通知 User + 跳转 - // 用 **pendingRedirect flag**(不是 useRef transition detection)—— - // re-mount 时 flag 在 auth context 里**持久**,区分"刚按了"和"re-visit"准确无误。 + // 用 pendingRedirect flag(不是 useRef transition detection)—— + // re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。 useEffect(() => { console.log("[auth-screen] useEffect (loginStatus + pendingRedirect)", { pending: state.pendingRedirect, diff --git a/src/app/chat/components/chat-header.tsx b/src/app/chat/components/chat-header.tsx index 6bf693bf..7e0bdcdb 100644 --- a/src/app/chat/components/chat-header.tsx +++ b/src/app/chat/components/chat-header.tsx @@ -8,7 +8,7 @@ * - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth) * - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → push 到 /sidebar) * - * 注意:菜单按钮直接 push 到侧边栏路由,**不**打开下拉菜单 + * 注意:菜单按钮直接 push 到侧边栏路由,不打开下拉菜单 * (Dart 端 `MenuButton(onTap: _goSidebar)` 行为)。 */ import { useRouter } from "next/navigation"; diff --git a/src/app/chat/components/chat-quota-exhausted-banner.module.css b/src/app/chat/components/chat-quota-exhausted-banner.module.css new file mode 100644 index 00000000..7c82fe05 --- /dev/null +++ b/src/app/chat/components/chat-quota-exhausted-banner.module.css @@ -0,0 +1,50 @@ +/* ChatQuotaExhaustedBanner — 游客配额耗尽横幅 + * 视觉规格(与设计稿对齐): + * - 粉→品红渐变背景(与 splash gradient 一致) + * - 居中文案 + 渐变 pill 按钮 + * - 圆角 + 阴影(与 /sidebar 已有卡片的视觉重量对齐) + */ + +.banner { + flex: 0 0 auto; + margin: 0 var(--spacing-lg, 16px) var(--spacing-lg, 16px); + padding: var(--spacing-xl, 24px) var(--spacing-lg, 16px); + border-radius: 24px; + background: linear-gradient(135deg, #f96ade 0%, #f657a0 100%); + text-align: center; + box-shadow: 0 4px 16px rgba(248, 77, 150, 0.25); +} + +.text { + color: #fff; + font-size: 1rem; + font-weight: 600; + line-height: 1.4; + margin: 0 0 var(--spacing-lg, 16px); + /* 与 splash 标题的粉色字保持视觉一致(白底渐变叠加层需要白字保证可读性) */ + opacity: 0.95; +} + +.cta { + width: 100%; + padding: 0.875rem 1.5rem; + border: none; + border-radius: 999px; + background: linear-gradient(135deg, #ffb8e0 0%, #f57ec0 100%); + color: #fff; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; +} + +.cta:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.cta:active { + transform: scale(0.98); +} diff --git a/src/app/chat/components/chat-quota-exhausted-banner.tsx b/src/app/chat/components/chat-quota-exhausted-banner.tsx new file mode 100644 index 00000000..96dbb2b4 --- /dev/null +++ b/src/app/chat/components/chat-quota-exhausted-banner.tsx @@ -0,0 +1,73 @@ +"use client"; +/** + * ChatQuotaExhaustedBanner 游客配额耗尽横幅 + * + * 何时显示: + * - isGuest === true(仅游客业务事实"非游客无配额上限") + * - quotaLoaded === true(避免一进 /chat 就跳 banner) + * - guestTotalQuota <= 0(用 total 而非 remaining —— 配额刚减为 0 不立即跳) + * + * 视觉规格(与设计稿对齐): + * - 粉→品红渐变背景(与 splash 渐变一致) + * - 大字号粉色/白色提示文案("The limit for free chat times has been reached today") + * - 粉→品红渐变 pill 按钮"Unlock your membership to continue" → 跳 /subscription + * + * 业务关系: + * - 与 `src/app/sidebar/components/vip-benefits-card.tsx` 的 "Activate VIP Membership" 行为一致 + * - 与 `src/app/sidebar/components/voice-package-card.tsx` 的 "Buy Package" 行为一致 + * + * 不做的事: + * - 不直接管理 state machine(chat 机器不感知 UI 层) + * - 不显示具体剩余分钟数(已用 total<=0 表达"耗尽"足够) + */ +import { useRouter } from "next/navigation"; + +import { ROUTES } from "@/router/routes"; + +import styles from "./chat-quota-exhausted-banner.module.css"; + +export interface ChatQuotaExhaustedBannerProps { + /** + * 自定义点击回调(不传则默认 router.push(ROUTES.subscription)) + * - 测试时可传 mock + * - 嵌入其他场景时(如 nested overlay)可传自定义 + */ + onUnlock?: () => void; +} + +export function ChatQuotaExhaustedBanner({ + onUnlock, +}: ChatQuotaExhaustedBannerProps) { + const router = useRouter(); + + const handleClick = () => { + if (onUnlock) { + onUnlock(); + return; + } + router.push(ROUTES.subscription); + }; + + return ( +
+

+ The limit for free chat times +
+ has been reached today +

+ +
+ ); +} diff --git a/src/app/chat/components/chat-screen.tsx b/src/app/chat/components/chat-screen.tsx index 346e8ee7..c2103c1a 100644 --- a/src/app/chat/components/chat-screen.tsx +++ b/src/app/chat/components/chat-screen.tsx @@ -2,12 +2,10 @@ /** * ChatScreen 编排层 * - * 原始 Dart: lib/ui/chat/chat_screen.dart(172 行) - * * 职责(本文件): - * - **订阅 auth 状态机**(loginStatus)—— 派生 isGuest / **派鉴权生命周期事件**给 chat 机器 + * - 订阅 auth 状态机(loginStatus)—— 派生 isGuest / 派鉴权生命周期事件给 chat 机器 * - 屏幕生命周期事件(init / visible / invisible) - * - **不**直接管理 WebSocket / **不**直接读 AuthStorage(除 token 取值) + * - 不直接管理 WebSocket / 不直接读 AuthStorage(除 token 取值) * * 子组件职责: * - ChatHeader:顶部栏(游客 banner / 菜单按钮) @@ -16,13 +14,13 @@ * - PwaInstallOverlay:PWA 安装提示触发器 * - BrowserHintOverlay:浏览器提示 * - * **鉴权解耦**(**事件驱动**): - * - chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus - * - chat-screen **只**派 3 个事件: - * - `ChatGuestLogin` → 游客会话(**断** WS = 不连) - * - `ChatNonGuestLogin { token }` → 非游客会话(**连** WS) + * 鉴权解耦(事件驱动): + * - chat 机器不感知鉴权 / 不管 WebSocket —— 由 chat-screen 派生 loginStatus + * - chat-screen 只派 3 个事件: + * - `ChatGuestLogin` → 游客会话(断 WS = 不连) + * - `ChatNonGuestLogin { token }` → 非游客会话(连 WS) * - `ChatLogout` → 登出(机器自动 cleanup WS actor) - * - chat 机器**内部**用 fromCallback actor 完整管理 WS 生命周期 + * - chat 机器内部用 fromCallback actor 完整管理 WS 生命周期 */ import { useEffect, useRef } from "react"; import Image from "next/image"; @@ -38,9 +36,11 @@ import { BrowserHintOverlay } from "./browser-hint-overlay"; import { ChatArea } from "./chat-area"; import { ChatHeader } from "./chat-header"; import { ChatInputBar } from "./chat-input-bar"; +import { ChatQuotaExhaustedBanner } from "./chat-quota-exhausted-banner"; import { PwaInstallOverlay } from "./pwa-install-overlay"; import styles from "./chat-screen.module.css"; + /** * 派生 isGuest —— 单一来源:`auth.loginStatus` */ @@ -49,16 +49,16 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean { } /** - * 按 loginStatus 派鉴权生命周期事件(chat 机器**不**感知 loginStatus —— 这层转换在此) + * 按 loginStatus 派鉴权生命周期事件(chat 机器不感知 loginStatus —— 这层转换在此) * - * 设计:**不**直接管理 WS / **不**读 AuthStorage(除取 token) + * 设计:不直接管理 WS / 不读 AuthStorage(除取 token) */ function dispatchAuthLifecycle( loginStatus: LoginStatus, chatDispatch: ReturnType, ): void { if (loginStatus === "guest") { - // 游客:派 ChatGuestLogin(chat 机器**不**连 WS —— 业务事实"断 WS") + // 游客:派 ChatGuestLogin(chat 机器不连 WS —— 业务事实"断 WS") chatDispatch({ type: "ChatGuestLogin" }); return; } @@ -81,9 +81,20 @@ export function ChatScreen() { const authState = useAuthState(); const chatDispatch = useChatDispatch(); - // isGuest 派生(chat-screen 是**唯一**知道这个值的地方 —— chat 机器无 isGuest 字段) + // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) const isGuest = deriveIsGuest(authState.loginStatus); + // 游客配额耗尽 → 显示 Unlock CTA banner(替代 ChatInputBar) + // 三重条件:游客 + 配额加载完 + 用户被配额 guard 拦截过至少一次 + // - quotaLoaded 避免一进 /chat 就跳 banner(loadQuota 还在 in-flight) + // - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota —— + // trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1, + // 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断) + const showExhaustedBanner = + isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0; + + + // ───────────────────────────────────────────────────────────── // 屏幕生命周期 + 派初次鉴权生命周期事件 // ───────────────────────────────────────────────────────────── @@ -91,7 +102,7 @@ export function ChatScreen() { dispatchAuthLifecycle(authState.loginStatus, chatDispatch); return () => { }; - // 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的 + // 注:mount useEffect 故意不依赖 loginStatus —— 首次 mount 时机是固定的 // ("用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。 // loginStatus 后续变化由下方"副作用 ②" 统一处理。 // eslint-disable-next-line react-hooks/exhaustive-deps @@ -110,7 +121,7 @@ export function ChatScreen() { const prev = prevLoginStatusRef.current; prevLoginStatusRef.current = authState.loginStatus; if (prev === null) return; // 首次 mount 由 mount useEffect 处理 - if (prev === authState.loginStatus) return; // 登录态**没**变 + if (prev === authState.loginStatus) return; // 登录态没变 dispatchAuthLifecycle(authState.loginStatus, chatDispatch); }, [authState.loginStatus, chatDispatch]); @@ -138,7 +149,12 @@ export function ChatScreen() { isGuest={isGuest} /> - + {showExhaustedBanner ? ( + + ) : ( + + )} + {/* 浏览器提示(应用内浏览器检测) */} diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index 8b298016..5a830fd3 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -8,7 +8,9 @@ export * from "./chat-area"; export * from "./chat-header"; export * from "./chat-input-bar"; export * from "./chat-input-text-field"; +export * from "./chat-quota-exhausted-banner"; export * from "./chat-screen"; + export * from "./chat-send-button"; export * from "./date-header"; export * from "./fullscreen-image-viewer"; diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index dea5e557..e55671b6 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -29,7 +29,7 @@ export function MessageContent({ content, imageUrl, isFromAI }: MessageContentPr style={{ display: "flex", flexDirection: "column", - // **关键**:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度 + // 关键:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度 flex: "1 1 0", // 防超长 content(图片 / 超长 url)撑爆父 minWidth: 0, diff --git a/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx b/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx index c753a45d..a8157fd1 100644 --- a/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx +++ b/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx @@ -7,7 +7,7 @@ * `redirect` 回调(`app_router.dart:60-78`)。 * * 设计: - * - **不**走 Server `redirect()`:避免把 fbid 暴露在 URL 里。 + * - 不走 Server `redirect()`:避免把 fbid 暴露在 URL 里。 * - deviceId 走 `AuthStorage.getInstance().setDeviceId`(与 `token_interceptor.ts` 同源; * 写入键 `cozsweet.deviceId`)。 * - fbid 走 `window.localStorage.setItem(StorageKeys.facebookId, ...)`, diff --git a/src/app/chat/deviceid/[deviceId]/page.tsx b/src/app/chat/deviceid/[deviceId]/page.tsx index 31de2bb0..687530a1 100644 --- a/src/app/chat/deviceid/[deviceId]/page.tsx +++ b/src/app/chat/deviceid/[deviceId]/page.tsx @@ -9,7 +9,7 @@ * Next.js 16 全局 helper `PageProps<'/literal'>` 由 `next dev` / `next build` / * `next typegen` 生成。 * - * 这里**不**调用 `redirect()`:把 fbid 暴露在 URL 上对用户不友好; + * 这里不调用 `redirect()`:把 fbid 暴露在 URL 上对用户不友好; * 跳转推迟到 Client 完成持久化之后。 */ diff --git a/src/app/page.tsx b/src/app/page.tsx index 8b5815ec..3b935c5e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,7 +4,7 @@ * Server Component:把访客直接送入 `/splash`,由 splash 页(Client)根据 * `authStorage.hasLoginToken()` 决定是否再跳 `/chat`。 * - * 这里**不**做鉴权重定向:Server 端读不到 localStorage,且当前没有 HttpOnly + * 这里不做鉴权重定向:Server 端读不到 localStorage,且当前没有 HttpOnly * cookie(见 `src/data/storage/auth/auth_storage.ts` 的 TODO)。 * `src/proxy.ts` 已为未来 cookie 化预留了基于 `login_token` cookie 的 short-circuit。 */ diff --git a/src/app/splash/components/splash-button.module.css b/src/app/splash/components/splash-button.module.css index 34bce9af..a1b4ca30 100644 --- a/src/app/splash/components/splash-button.module.css +++ b/src/app/splash/components/splash-button.module.css @@ -12,6 +12,7 @@ font-family: var(--font-athelas); font-size: var(--font-size-xxl); font-weight: 700; + font-style: italic; color: #ffffff; background: transparent; border: 0; @@ -43,6 +44,7 @@ color: #ffffff; font-weight: 700; cursor: pointer; + font-style: italic; display: inline-flex; align-items: center; justify-content: center; diff --git a/src/app/splash/components/splash-button.tsx b/src/app/splash/components/splash-button.tsx index ab82cac4..6e4c81c9 100644 --- a/src/app/splash/components/splash-button.tsx +++ b/src/app/splash/components/splash-button.tsx @@ -2,14 +2,14 @@ /** * Splash 底部按钮组 * - * 本组件是 splash 鉴权流程的**纯 UI 入口**: + * 本组件是 splash 鉴权流程的纯 UI 入口: * - 消费 `useAuthState` / `useAuthDispatch`(仅用于 isLoading 显示 + 派事件) - * - **Skip 按钮 = 显式游客登录入口**(派发 `AuthGuestLoginSubmitted`) + * - Skip 按钮 = 显式游客登录入口(派发 `AuthGuestLoginSubmitted`) * - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth * - * **派事件策略**(**单**事件): + * 派事件策略(单事件): * - 派业务事件(`AuthGuestLoginSubmitted` / `AuthFacebookLoginSubmitted`)→ 状态机开始跑 - * - `pendingRedirect: true` 在**登录成功 onDone** 里**自动**置(auth-machine.ts `onLoginSuccess` helper) + * - `pendingRedirect: true` 在登录成功 onDone 里自动置(auth-machine.ts `onLoginSuccess` helper) * - splash-screen 看到 flag=true + loginStatus 非 notLoggedIn → 跳 /chat */ import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; @@ -35,7 +35,7 @@ export function SplashButton() { return (
- {/* Skip 按钮 = 显式游客登录入口(派发 AuthGuestLoginSubmitted,**不**做路由跳转) */} + {/* Skip 按钮 = 显式游客登录入口(派发 AuthGuestLoginSubmitted,不做路由跳转) */}