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:
2026-06-16 15:17:44 +08:00
parent 9ffa30cc03
commit dcb6312fa3
8 changed files with 153 additions and 39 deletions
@@ -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 machinechat 机器不感知 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>
);
}
+19 -3
View File
@@ -2,8 +2,6 @@
/**
* ChatScreen 编排层
*
* 原始 Dart: lib/ui/chat/chat_screen.dart172 行)
*
* 职责(本文件):
* - 订阅 auth 状态机(loginStatus)—— 派生 isGuest / 派鉴权生命周期事件给 chat 机器
* - 屏幕生命周期事件(init / visible / invisible
@@ -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`
*/
@@ -84,6 +84,17 @@ export function ChatScreen() {
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
// 游客配额耗尽 → 显示 Unlock CTA banner(替代 ChatInputBar
// 三重条件:游客 + 配额加载完 + 用户被配额 guard 拦截过至少一次
// - quotaLoaded 避免一进 /chat 就跳 bannerloadQuota 还在 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}
/>
<ChatInputBar disabled={state.isReplyingAI} />
{showExhaustedBanner ? (
<ChatQuotaExhaustedBanner />
) : (
<ChatInputBar disabled={state.isReplyingAI} />
)}
</div>
{/* 浏览器提示(应用内浏览器检测) */}
+2
View File
@@ -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";