feat(chat): add quota-exhausted banner for guest users
Introduce ChatQuotaExhaustedBanner component that displays a pink gradient banner with an "Unlock your membership to continue" CTA when a guest user's free chat quota has been exhausted. The banner is shown in ChatScreen when isGuest is true, quota has loaded, and the chat state machine's quotaExceededTrigger has been incremented by a quota guard. Default click navigates to /subscription, with an optional onUnlock callback for overrides/tests. Styles align with the existing splash and sidebar VIP card visual language.
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={styles.banner}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
data-testid="chat-quota-exhausted-banner"
|
||||||
|
>
|
||||||
|
<p className={styles.text}>
|
||||||
|
The limit for free chat times
|
||||||
|
<br />
|
||||||
|
has been reached today
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cta}
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-label="Unlock your membership to continue"
|
||||||
|
>
|
||||||
|
Unlock your membership to continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
/**
|
/**
|
||||||
* ChatScreen 编排层
|
* ChatScreen 编排层
|
||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/chat/chat_screen.dart(172 行)
|
|
||||||
*
|
|
||||||
* 职责(本文件):
|
* 职责(本文件):
|
||||||
* - 订阅 auth 状态机(loginStatus)—— 派生 isGuest / 派鉴权生命周期事件给 chat 机器
|
* - 订阅 auth 状态机(loginStatus)—— 派生 isGuest / 派鉴权生命周期事件给 chat 机器
|
||||||
* - 屏幕生命周期事件(init / visible / invisible)
|
* - 屏幕生命周期事件(init / visible / invisible)
|
||||||
@@ -38,9 +36,11 @@ import { BrowserHintOverlay } from "./browser-hint-overlay";
|
|||||||
import { ChatArea } from "./chat-area";
|
import { ChatArea } from "./chat-area";
|
||||||
import { ChatHeader } from "./chat-header";
|
import { ChatHeader } from "./chat-header";
|
||||||
import { ChatInputBar } from "./chat-input-bar";
|
import { ChatInputBar } from "./chat-input-bar";
|
||||||
|
import { ChatQuotaExhaustedBanner } from "./chat-quota-exhausted-banner";
|
||||||
import { PwaInstallOverlay } from "./pwa-install-overlay";
|
import { PwaInstallOverlay } from "./pwa-install-overlay";
|
||||||
import styles from "./chat-screen.module.css";
|
import styles from "./chat-screen.module.css";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 派生 isGuest —— 单一来源:`auth.loginStatus`
|
* 派生 isGuest —— 单一来源:`auth.loginStatus`
|
||||||
*/
|
*/
|
||||||
@@ -84,6 +84,17 @@ export function ChatScreen() {
|
|||||||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||||
const isGuest = deriveIsGuest(authState.loginStatus);
|
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;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// 屏幕生命周期 + 派初次鉴权生命周期事件
|
// 屏幕生命周期 + 派初次鉴权生命周期事件
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -138,7 +149,12 @@ export function ChatScreen() {
|
|||||||
isGuest={isGuest}
|
isGuest={isGuest}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showExhaustedBanner ? (
|
||||||
|
<ChatQuotaExhaustedBanner />
|
||||||
|
) : (
|
||||||
<ChatInputBar disabled={state.isReplyingAI} />
|
<ChatInputBar disabled={state.isReplyingAI} />
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 浏览器提示(应用内浏览器检测) */}
|
{/* 浏览器提示(应用内浏览器检测) */}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export * from "./chat-area";
|
|||||||
export * from "./chat-header";
|
export * from "./chat-header";
|
||||||
export * from "./chat-input-bar";
|
export * from "./chat-input-bar";
|
||||||
export * from "./chat-input-text-field";
|
export * from "./chat-input-text-field";
|
||||||
|
export * from "./chat-quota-exhausted-banner";
|
||||||
export * from "./chat-screen";
|
export * from "./chat-screen";
|
||||||
|
|
||||||
export * from "./chat-send-button";
|
export * from "./chat-send-button";
|
||||||
export * from "./date-header";
|
export * from "./date-header";
|
||||||
export * from "./fullscreen-image-viewer";
|
export * from "./fullscreen-image-viewer";
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* ChatStorage 完整实现(基于 unstorage 抽象)
|
|
||||||
*
|
|
||||||
* 对齐 Dart 端 `ChatStorage`(lib/data/services/storage/chat/chat_storage.dart):
|
|
||||||
* - 游客每日聊天配额(GuestChatQuota:{ remaining, date })
|
|
||||||
* - 游客总配额(int)
|
|
||||||
*
|
|
||||||
* 注意:本类与 `LocalChatStorage`(IndexedDB / Dexie)是不同类:
|
|
||||||
* - ChatStorage = 游客配额等元数据(KV)
|
|
||||||
* - LocalChatStorage = 聊天记录本身(IndexedDB)
|
|
||||||
*
|
|
||||||
* 设计:内部使用 SpAsyncUtil 静态类 + Zod 校验;保持对外 API 完全兼容。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GuestChatQuotaSchema,
|
GuestChatQuotaSchema,
|
||||||
type GuestChatQuotaData,
|
type GuestChatQuotaData,
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/**
|
|
||||||
* ChatContext:基于 XState v5 的 React Context Provider
|
|
||||||
*
|
|
||||||
* 业务事件通过 `useMachine(chatMachine).send()` 派发。
|
|
||||||
* XState 自动处理 HTTP 副作用(invoke + fromPromise actors)。
|
|
||||||
*
|
|
||||||
* 兼容性:保持原 `useChatState()` / `useChatDispatch()` API。
|
|
||||||
* 内部将 XState 状态机快照映射回原 `ChatState` 形状。
|
|
||||||
*
|
|
||||||
* 注:WebSocket 长生命周期 actor 由 chat-screen.tsx 监听 auth 状态机驱动——
|
|
||||||
* 鉴权解耦(loginStatus 在 auth,chat 机器不感知鉴权)。
|
|
||||||
*/
|
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
|||||||
@@ -33,7 +33,8 @@
|
|||||||
import { setup, assign } from "xstate";
|
import { setup, assign } from "xstate";
|
||||||
|
|
||||||
import { ChatStorage } from "@/data/storage/chat/chat_storage";
|
import { ChatStorage } from "@/data/storage/chat/chat_storage";
|
||||||
import { formatDate } from "@/utils/date";
|
import { formatDate, todayString } from "@/utils/date";
|
||||||
|
|
||||||
|
|
||||||
import { ChatState, initialState } from "./chat-state";
|
import { ChatState, initialState } from "./chat-state";
|
||||||
import type { ChatEvent } from "./chat-events";
|
import type { ChatEvent } from "./chat-events";
|
||||||
@@ -85,8 +86,9 @@ export const chatMachine = setup({
|
|||||||
|
|
||||||
// 持久化两个额度( fire-and-forget,不 await —— assign 是 sync)
|
// 持久化两个额度( fire-and-forget,不 await —— assign 是 sync)
|
||||||
// daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset)
|
// daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset)
|
||||||
const today = formatDate();
|
const today = todayString();
|
||||||
ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
|
ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
|
||||||
|
|
||||||
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
||||||
|
|
||||||
console.log("[chat-machine] appendUserMessage", {
|
console.log("[chat-machine] appendUserMessage", {
|
||||||
@@ -124,9 +126,12 @@ export const chatMachine = setup({
|
|||||||
const newTotal = context.wsConnected
|
const newTotal = context.wsConnected
|
||||||
? context.guestTotalQuota
|
? context.guestTotalQuota
|
||||||
: Math.max(0, context.guestTotalQuota - 1);
|
: Math.max(0, context.guestTotalQuota - 1);
|
||||||
const today = formatDate();
|
|
||||||
|
const today = todayString();
|
||||||
void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
|
void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today);
|
||||||
|
|
||||||
void ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
void ChatStorage.getInstance().setGuestTotalQuota(newTotal);
|
||||||
|
|
||||||
console.log("[chat-machine] appendUserImage", {
|
console.log("[chat-machine] appendUserImage", {
|
||||||
oldMessagesCount: context.messages.length,
|
oldMessagesCount: context.messages.length,
|
||||||
wsConnected: context.wsConnected,
|
wsConnected: context.wsConnected,
|
||||||
|
|||||||
@@ -3,13 +3,6 @@
|
|||||||
*
|
*
|
||||||
* 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。
|
* 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。
|
||||||
*
|
*
|
||||||
* 历史:
|
|
||||||
* - `isGuest: boolean` 字段已删(由 chat-screen 派生自 `auth.loginStatus === "guest"`)
|
|
||||||
* - `guestRemainingQuota` / `guestTotalQuota` default 改 0:
|
|
||||||
* - 游客:chat-screen 派 `ChatInit` → readInitData 调 ChatStorage → onDone 条件赋
|
|
||||||
* - 非游客:永不被赋值(业务事实"非游客无消息限制")—— 0 - 1 = max(0, -1) = 0 天然 no-op
|
|
||||||
* - 0 同时表示"未初始化"和"已耗尽" —— UI 用 `if (isGuest && ...)` 天然过滤
|
|
||||||
*
|
|
||||||
* 新增 `wsConnected: boolean`(仅 userSession 期间 true):
|
* 新增 `wsConnected: boolean`(仅 userSession 期间 true):
|
||||||
* - WS 已连 → 消息发送走 WS,不消耗次数
|
* - WS 已连 → 消息发送走 WS,不消耗次数
|
||||||
* - WS 未连 → 消息发送走 HTTP,消耗次数
|
* - WS 未连 → 消息发送走 HTTP,消耗次数
|
||||||
|
|||||||
Reference in New Issue
Block a user