refactor(app): migrate banner styles to tailwind
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatHeader } from "../chat-header";
|
||||
import { ChatInsufficientCreditsBanner } from "../chat-insufficient-credits-banner";
|
||||
import { ChatSendButton } from "../chat-send-button";
|
||||
import { ImageBubble } from "../image-bubble";
|
||||
import { MessageAvatar } from "../message-avatar";
|
||||
import { PrivateMessageCard } from "../private-message-card";
|
||||
import { TextBubble } from "../text-bubble";
|
||||
|
||||
vi.mock("@/router/use-app-navigator", () => ({
|
||||
useAppNavigator: () => ({
|
||||
openSubscription: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("chat Tailwind components", () => {
|
||||
it("renders MessageAvatar AI and user branches with image utilities", () => {
|
||||
const aiHtml = renderToStaticMarkup(<MessageAvatar isFromAI={true} />);
|
||||
@@ -116,4 +124,40 @@ describe("chat Tailwind components", () => {
|
||||
);
|
||||
expect(paywalledHtml).toContain("data:image/png;base64,/locked-image.png");
|
||||
});
|
||||
|
||||
it("renders ChatInsufficientCreditsBanner with split title and CTA", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ChatInsufficientCreditsBanner
|
||||
title={"Need credits\nTop up now"}
|
||||
description="Credits keep the conversation going."
|
||||
ctaLabel="Top up"
|
||||
onUnlock={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain("Need credits");
|
||||
expect(html).toContain("<br/>");
|
||||
expect(html).toContain("Top up now");
|
||||
expect(html).toContain("Credits keep the conversation going.");
|
||||
expect(html).toContain('aria-label="Top up"');
|
||||
expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]");
|
||||
expect(html).toContain("active:scale-[0.98]");
|
||||
});
|
||||
|
||||
it("renders ChatHeader guest and member branches", () => {
|
||||
const guestHtml = renderToStaticMarkup(<ChatHeader isGuest={true} />);
|
||||
const memberHtml = renderToStaticMarkup(
|
||||
<ChatHeader isGuest={false} offerBanner={<div>Offer</div>} />,
|
||||
);
|
||||
|
||||
expect(guestHtml).toContain('aria-label="Sign up to unlock more features"');
|
||||
expect(guestHtml).toContain("bg-[var(--color-accent,#f84d96)]");
|
||||
expect(guestHtml).toContain("size-[var(--icon-size-sm,16px)]");
|
||||
expect(memberHtml).toContain("Offer");
|
||||
expect(memberHtml).toContain('aria-label="Menu"');
|
||||
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
|
||||
expect(memberHtml).toContain("size-[var(--icon-size-xl,32px)]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/* ChatHeader 顶部栏样式(与 Dart chat_header.dart 对齐) */
|
||||
|
||||
/* 外层:等价于 Dart `Column(mainAxisSize: MainAxisSize.min)` — 无 padding/背景 */
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 游客模式横幅(Dart: Container(color: accent, padding: vertical 8 / horizontal 12)) */
|
||||
.guestBanner {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
|
||||
background: var(--color-accent, #f84d96);
|
||||
color: #fff;
|
||||
font-size: var(--responsive-caption, var(--font-size-sm, 12px));
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guestBannerIcon {
|
||||
width: var(--icon-size-sm, 16px);
|
||||
height: var(--icon-size-sm, 16px);
|
||||
}
|
||||
|
||||
/* 登录模式行容器(Dart: Container(margin-left 12, padding: vertical 8 / horizontal 12)) */
|
||||
.headerRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-left: var(--spacing-md, 12px);
|
||||
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
|
||||
}
|
||||
|
||||
/* 菜单按钮(Dart: Container(padding 8, bg #0d0b14 @ 70%, radius 32)) */
|
||||
.menuButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-sm, 8px);
|
||||
background: rgba(13, 11, 20, 0.7); /* AppColors.darkBackground #0d0b14 @ alpha 0.7 */
|
||||
border-radius: var(--radius-full, 999px);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-primary, #fff);
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
width: var(--icon-size-xl, 32px);
|
||||
height: var(--icon-size-xl, 32px);
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import { Lock, Menu } from "lucide-react";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
|
||||
import styles from "./chat-header.module.css";
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
isGuest: boolean;
|
||||
offerBanner?: ReactNode;
|
||||
@@ -23,14 +21,18 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
|
||||
|
||||
if (isGuest) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.guestBanner}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-[var(--spacing-sm,8px)] border-0 bg-[var(--color-accent,#f84d96)] px-[var(--spacing-md,12px)] py-[var(--spacing-sm,8px)] text-center text-[var(--responsive-caption,var(--font-size-sm,12px))] font-medium text-white"
|
||||
onClick={() => navigator.openAuth(ROUTES.chat)}
|
||||
aria-label="Sign up to unlock more features"
|
||||
>
|
||||
<Lock className={styles.guestBannerIcon} size={16} aria-hidden="true" />
|
||||
<Lock
|
||||
className="size-[var(--icon-size-sm,16px)]"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Sign up to unlock more features</span>
|
||||
</button>
|
||||
</header>
|
||||
@@ -38,18 +40,22 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
|
||||
{offerBanner}
|
||||
|
||||
<div className={styles.headerRow}>
|
||||
<div className="ml-[var(--spacing-md,12px)] flex items-center justify-between px-[var(--spacing-md,12px)] py-[var(--spacing-sm,8px)]">
|
||||
{/* 菜单按钮(点击 → push 到 /sidebar,与 Dart MenuButton 一致) */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuButton}
|
||||
className="flex cursor-pointer items-center justify-center rounded-[var(--radius-full,999px)] border-0 bg-[rgba(13,11,20,0.7)] p-[var(--spacing-sm,8px)] text-[var(--color-text-primary,#fff)]"
|
||||
onClick={() => navigator.push(ROUTES.sidebar)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<Menu className={styles.menuIcon} size={24} aria-hidden="true" />
|
||||
<Menu
|
||||
className="size-[var(--icon-size-xl,32px)]"
|
||||
size={24}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/* ChatInsufficientCreditsBanner — 积分不足横幅
|
||||
* 视觉规格(与设计稿对齐):
|
||||
* - 粉→品红渐变背景(与 splash gradient 一致)
|
||||
* - 居中文案 + 渐变 pill 按钮
|
||||
* - 圆角 + 阴影(与 /sidebar 已有卡片的视觉重量对齐)
|
||||
*/
|
||||
|
||||
.banner {
|
||||
flex: 0 0 auto;
|
||||
margin:
|
||||
0
|
||||
var(--chat-inline-padding, 16px)
|
||||
var(--page-section-gap, 16px);
|
||||
padding:
|
||||
var(--page-section-gap-lg, 24px)
|
||||
var(--responsive-card-padding, 16px);
|
||||
border-radius: var(--responsive-card-radius-sm, 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: var(--responsive-card-title, 18px);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 var(--spacing-sm, 8px);
|
||||
/* 与 splash 标题的粉色字保持视觉一致(白底渐变叠加层需要白字保证可读性) */
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: min(100%, 360px);
|
||||
margin: 0 auto var(--spacing-lg, 16px);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: var(--responsive-caption, 14px);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cta {
|
||||
width: 100%;
|
||||
min-height: var(--responsive-control-height, 48px);
|
||||
padding: 0 var(--responsive-card-padding-lg, 24px);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #ffb8e0 0%, #f57ec0 100%);
|
||||
color: #fff;
|
||||
font-size: var(--responsive-card-title, 18px);
|
||||
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);
|
||||
}
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
|
||||
import styles from "./chat-insufficient-credits-banner.module.css";
|
||||
|
||||
export interface ChatInsufficientCreditsBannerProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
@@ -51,12 +49,12 @@ export function ChatInsufficientCreditsBanner({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.banner}
|
||||
className="mx-[var(--chat-inline-padding,16px)] mb-[var(--page-section-gap,16px)] shrink-0 rounded-[var(--responsive-card-radius-sm,24px)] bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)] px-[var(--responsive-card-padding,16px)] py-[var(--page-section-gap-lg,24px)] text-center shadow-[0_4px_16px_rgba(248,77,150,0.25)]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="chat-insufficient-credits-banner"
|
||||
>
|
||||
<p className={styles.text}>
|
||||
<p className="m-0 mb-[var(--spacing-sm,8px)] text-[var(--responsive-card-title,18px)] font-semibold leading-[1.4] text-white opacity-95">
|
||||
{titleLines.map((line, index) => (
|
||||
<span key={`${line}-${index}`}>
|
||||
{line}
|
||||
@@ -65,11 +63,13 @@ export function ChatInsufficientCreditsBanner({
|
||||
))}
|
||||
</p>
|
||||
{description ? (
|
||||
<p className={styles.description}>{description}</p>
|
||||
<p className="mx-auto mb-[var(--spacing-lg,16px)] mt-0 max-w-[min(100%,360px)] text-[var(--responsive-caption,14px)] font-semibold leading-[1.4] text-[rgba(255,255,255,0.88)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cta}
|
||||
className="min-h-[var(--responsive-control-height,48px)] w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(135deg,#ffb8e0_0%,#f57ec0_100%)] px-[var(--responsive-card-padding-lg,24px)] text-[var(--responsive-card-title,18px)] font-bold text-white shadow-[0_2px_8px_rgba(0,0,0,0.15)] transition-[box-shadow,transform] duration-150 hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)] active:scale-[0.98]"
|
||||
onClick={handleClick}
|
||||
aria-label={ctaLabel}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SplashBackground } from "../splash-background";
|
||||
import { SplashButton } from "../splash-button";
|
||||
import { SplashContent } from "../splash-content";
|
||||
import { SplashLogo } from "../splash-logo";
|
||||
|
||||
const authStateMock = vi.hoisted(() => ({
|
||||
value: {
|
||||
authPanelMode: "facebook",
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
loginStatus: "notLoggedIn",
|
||||
hasInitialized: true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/auth/auth-context", () => ({
|
||||
useAuthState: () => authStateMock.value,
|
||||
}));
|
||||
|
||||
describe("splash Tailwind components", () => {
|
||||
it("renders SplashLogo with Tailwind wrapper and image classes", () => {
|
||||
const html = renderToStaticMarkup(<SplashLogo />);
|
||||
@@ -32,4 +47,31 @@ describe("splash Tailwind components", () => {
|
||||
expect(html).toContain("whitespace-pre-line");
|
||||
expect(html).toContain("Welcome to my secret hideout");
|
||||
});
|
||||
|
||||
it("renders SplashButton ready and loading states", () => {
|
||||
authStateMock.value = {
|
||||
...authStateMock.value,
|
||||
hasInitialized: true,
|
||||
isLoading: false,
|
||||
};
|
||||
const readyHtml = renderToStaticMarkup(
|
||||
<SplashButton onStartChat={() => undefined} />,
|
||||
);
|
||||
|
||||
authStateMock.value = {
|
||||
...authStateMock.value,
|
||||
hasInitialized: false,
|
||||
isLoading: false,
|
||||
};
|
||||
const loadingHtml = renderToStaticMarkup(
|
||||
<SplashButton onStartChat={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(readyHtml).toContain("z-[2]");
|
||||
expect(readyHtml).toContain("max-w-[480px]");
|
||||
expect(readyHtml).toContain("bg-[linear-gradient(to_right");
|
||||
expect(readyHtml).toContain("Start Chatting");
|
||||
expect(loadingHtml).toContain("disabled");
|
||||
expect(loadingHtml).toContain("animate-spin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px));
|
||||
width: 100%;
|
||||
padding: 0 clamp(var(--spacing-sm, 8px), 2.963vw, var(--spacing-lg, 16px));
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.facebookButton {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
min-height: var(--responsive-control-height, 48px);
|
||||
padding: 0 clamp(var(--spacing-lg, 16px), 5.556vw, 30px);
|
||||
border: 0;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
/* 渐变色引用 tokens/colors.css 中定义的 token */
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-button-gradient-start),
|
||||
var(--color-button-gradient-end)
|
||||
);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-style: italic;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
box-shadow: 0 0 10px rgba(248, 89, 168, 0.3);
|
||||
}
|
||||
|
||||
.facebookButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.facebookLabel {
|
||||
font-size: var(--responsive-section-title, var(--font-size-xxl));
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
|
||||
import styles from "./splash-button.module.css";
|
||||
|
||||
export interface SplashButtonProps {
|
||||
onStartChat: () => void;
|
||||
@@ -15,17 +14,19 @@ export function SplashButton({ onStartChat }: SplashButtonProps) {
|
||||
const isLoading = !state.hasInitialized || state.isLoading;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className="z-[2] flex w-full flex-row items-center justify-center gap-[clamp(var(--spacing-md,12px),4.815vw,var(--spacing-26,26px))] px-[clamp(var(--spacing-sm,8px),2.963vw,var(--spacing-lg,16px))]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartChat}
|
||||
disabled={isLoading}
|
||||
className={styles.facebookButton}
|
||||
className="inline-flex min-h-[var(--responsive-control-height,48px)] w-full max-w-[480px] flex-auto cursor-pointer items-center justify-center rounded-[var(--radius-full,999px)] border-0 bg-[linear-gradient(to_right,var(--color-button-gradient-start),var(--color-button-gradient-end))] px-[clamp(var(--spacing-lg,16px),5.556vw,30px)] font-bold italic text-white shadow-[0_0_10px_rgba(248,89,168,0.3)] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingIndicator color="#ffffff" />
|
||||
) : (
|
||||
<span className={styles.facebookLabel}>Start Chatting</span>
|
||||
<span className="whitespace-nowrap text-[var(--responsive-section-title,var(--font-size-xxl))] font-bold text-white">
|
||||
Start Chatting
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user