refactor(sidebar): split state derivation

This commit is contained in:
2026-07-10 10:38:21 +08:00
parent 270e84385b
commit a1a93a51fb
13 changed files with 360 additions and 331 deletions
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import type { UserView } from "@/data/dto/user";
import {
getSidebarViewModel,
getSidebarWalletView,
normalizeSidebarCount,
} from "../sidebar-view-model";
const baseUser: UserView = {
id: "user-1",
username: "Chase",
countryCode: "US",
creditBalance: 120,
dailyFreeChatLimit: 30,
dailyFreeChatRemaining: 24,
dailyFreePrivateLimit: 4,
dailyFreePrivateRemaining: 2,
isVip: false,
};
describe("getSidebarViewModel", () => {
it("derives guest state for logged-out users", () => {
const view = getSidebarViewModel({
loginStatus: "notLoggedIn",
user: {
currentUser: null,
avatarUrl: null,
creditBalance: 0,
},
});
expect(view.state).toBe("guest");
expect(view.canActivateVip).toBe(true);
expect(view.canShowSettings).toBe(false);
});
it("derives member state and shows VIP activation for non-VIP users", () => {
const view = getSidebarViewModel({
loginStatus: "email",
user: {
currentUser: baseUser,
avatarUrl: "https://example.com/avatar.png",
creditBalance: 120,
},
});
expect(view.state).toBe("member");
expect(view.name).toBe("Chase");
expect(view.avatarUrl).toBe("https://example.com/avatar.png");
expect(view.canActivateVip).toBe(true);
expect(view.canShowSettings).toBe(true);
});
it("derives VIP state and hides VIP activation for VIP users", () => {
const view = getSidebarViewModel({
loginStatus: "email",
user: {
currentUser: { ...baseUser, isVip: true },
avatarUrl: null,
creditBalance: 120,
},
});
expect(view.state).toBe("vip");
expect(view.canActivateVip).toBe(false);
});
it("uses fallback name and marks logged-in users without profile as initializing", () => {
const view = getSidebarViewModel({
loginStatus: "guest",
user: {
currentUser: null,
avatarUrl: null,
creditBalance: null,
},
});
expect(view.state).toBe("member");
expect(view.name).toBe("User name");
expect(view.isInitializingUser).toBe(true);
});
});
describe("getSidebarWalletView", () => {
it("normalizes missing, negative, and fractional wallet values", () => {
const view = getSidebarWalletView({
currentUser: {
...baseUser,
dailyFreeChatLimit: -1,
dailyFreeChatRemaining: 12.9,
dailyFreePrivateLimit: Number.NaN,
dailyFreePrivateRemaining: 3.7,
},
avatarUrl: null,
creditBalance: -42.8,
});
expect(view).toEqual({
creditBalance: 0,
dailyFreeChatLimit: 0,
dailyFreeChatRemaining: 12,
dailyFreePrivateLimit: 0,
dailyFreePrivateRemaining: 3,
});
});
it("normalizes undefined and null counts to zero", () => {
expect(normalizeSidebarCount(undefined)).toBe(0);
expect(normalizeSidebarCount(null)).toBe(0);
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { shouldKeepPwaInstallEntryVisible } from "../use-pwa-install-entry";
describe("shouldKeepPwaInstallEntryVisible", () => {
it("hides the install entry after accepted installs", () => {
expect(
shouldKeepPwaInstallEntryVisible({
result: "accepted",
isInstalled: true,
canShowInstallEntry: true,
}),
).toBe(false);
});
it("keeps the install entry after dismissed prompts when still available", () => {
expect(
shouldKeepPwaInstallEntryVisible({
result: "dismissed",
isInstalled: false,
canShowInstallEntry: true,
}),
).toBe(true);
});
it("keeps the install entry after unavailable prompts when still available", () => {
expect(
shouldKeepPwaInstallEntryVisible({
result: "unavailable",
isInstalled: false,
canShowInstallEntry: true,
}),
).toBe(true);
});
it("hides the install entry when the platform can no longer show it", () => {
expect(
shouldKeepPwaInstallEntryVisible({
result: "dismissed",
isInstalled: false,
canShowInstallEntry: false,
}),
).toBe(false);
});
});
-1
View File
@@ -4,4 +4,3 @@
export * from "./sidebar-wallet-card";
export * from "./user-header";
export * from "./vip-benefits-card";
@@ -44,7 +44,6 @@
}
.topBar,
.hero,
.userSlot,
.cardSlot,
.settingsSlot {
@@ -56,45 +55,6 @@
margin: var(--page-padding-y, 18px) 0 -2px;
}
.hero {
padding: var(--responsive-card-padding, 18px);
border: 1px solid rgba(246, 87, 160, 0.12);
border-radius: var(--responsive-card-radius, 26px);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.76) 0%, rgba(255, 239, 246, 0.84) 100%),
#ffffff;
box-shadow: 0 14px 34px rgba(55, 36, 44, 0.07);
backdrop-filter: blur(16px);
}
.kicker {
margin: 0;
color: #f657a0;
font-size: var(--responsive-micro, 12px);
font-weight: 850;
letter-spacing: 0.14em;
line-height: 1;
text-transform: uppercase;
}
.title {
margin: clamp(6px, 1.481vw, 8px) 0 0;
color: #171114;
font-size: var(--responsive-display-title, 34px);
font-weight: 920;
letter-spacing: -1px;
line-height: 1;
}
.subtitle {
max-width: min(100%, 320px);
margin: clamp(8px, 1.852vw, 10px) 0 0;
color: #75636a;
font-size: var(--responsive-caption, 14px);
font-weight: 650;
line-height: 1.5;
}
.userSlot {
padding: var(--responsive-card-padding, 18px);
border: 1px solid rgba(25, 19, 22, 0.06);
@@ -256,14 +216,6 @@
padding-left: calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px));
}
.title {
font-size: var(--responsive-page-title, 30px);
}
.subtitle {
font-size: var(--responsive-caption, 13px);
}
.userSlot {
padding: var(--responsive-card-padding, 16px);
border-radius: var(--responsive-card-radius-sm, 22px);
@@ -6,10 +6,10 @@ import styles from "./sidebar-wallet-card.module.css";
export interface SidebarWalletCardProps {
creditBalance: number;
dailyFreeChatLimit?: number;
dailyFreeChatRemaining?: number;
dailyFreePrivateLimit?: number;
dailyFreePrivateRemaining?: number;
dailyFreeChatLimit: number;
dailyFreeChatRemaining: number;
dailyFreePrivateLimit: number;
dailyFreePrivateRemaining: number;
onActivateVip?: () => void;
onTopUp: () => void;
onRulesClick: () => void;
@@ -20,10 +20,10 @@ const formatCoins = (value: number): string =>
export function SidebarWalletCard({
creditBalance,
dailyFreeChatLimit = 0,
dailyFreeChatRemaining = 0,
dailyFreePrivateLimit = 0,
dailyFreePrivateRemaining = 0,
dailyFreeChatLimit,
dailyFreeChatRemaining,
dailyFreePrivateLimit,
dailyFreePrivateRemaining,
onActivateVip,
onTopUp,
onRulesClick,
+1 -2
View File
@@ -3,11 +3,10 @@
import Image from "next/image";
import { UserMessageAvatar } from "@/app/_components";
import type { SidebarUserState } from "@/app/sidebar/sidebar-view-model";
import styles from "./user-header.module.css";
export type SidebarUserState = "guest" | "member" | "vip";
export interface UserHeaderProps {
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
state: SidebarUserState;
@@ -1,123 +0,0 @@
.card {
background-image:
url("/images/sidebar/pic_bg_vip.png"),
linear-gradient(
-60deg,
rgba(251, 106, 67, 0.47) 0%,
rgba(252, 125, 185, 0.47) 32%,
rgba(250, 167, 186, 0.47) 58%,
rgba(255, 255, 255, 0.47) 100%
),
linear-gradient(#ffffff, #ffffff);
background-repeat: no-repeat, no-repeat, no-repeat;
background-position: right center, center, center;
background-size: 50% 100%, cover, cover;
background-blend-mode: normal, normal, normal;
border: solid 1px #fac4dc;
border-radius: var(--responsive-card-radius-sm, 21px);
width: 100%;
box-sizing: border-box;
padding: var(--sidebar-card-padding-y, 18px) 0
var(--sidebar-card-padding-y, 18px)
var(--sidebar-card-padding-x, 18px);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md, 12px);
padding: 0;
}
.title {
margin: 0;
font-size: var(--font-responsive-xl, 20px);
font-weight: 700;
color: #f657a0;
line-height: 1.3;
}
.statusPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px var(--spacing-md, 12px);
border-radius: var(--radius-full, 999px);
background: var(--color-pill-bg, rgba(248, 77, 150, 0.10));
color: var(--color-accent, #f84d96);
font-size: var(--font-size-xs, 10px);
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
flex-shrink: 0;
}
.list {
list-style: none;
margin: var(--page-section-gap, 18px) 0;
padding: 0;
}
.item {
display: flex;
align-items: flex-start;
gap: 0;
padding: 0;
font-size: var(--font-responsive-md, 16px);
color: #1e1e1e;
line-height: normal;
}
.numeral {
flex: 0 0 auto;
font-weight: 500;
color: #1e1e1e;
}
.numeral::after {
content: " ";
white-space: pre;
}
.text {
flex: 1 1 auto;
}
.footer {
padding: var(--spacing-md, 12px) 0 0;
}
.activateBtn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--responsive-card-radius-sm, 22px);
background-image:
linear-gradient(
135deg,
#ff67e0 0%,
rgba(255, 109, 225, 0.29) 20%,
rgba(254, 122, 228, 0.47) 66%,
rgba(252, 140, 231, 0.59) 100%
),
linear-gradient(#fb5e9d, #fb5e9d);
background-blend-mode: normal, normal;
color: var(--color-text-primary, #ffffff);
font-size: var(--font-responsive-md, 16px);
font-weight: 600;
border: 0;
cursor: pointer;
box-sizing: border-box;
}
.activateBtn:hover {
opacity: 0.9;
}
.activateBtn:focus-visible {
outline: 2px solid var(--color-accent, #f84d96);
outline-offset: 2px;
}
@@ -1,63 +0,0 @@
"use client";
import { VIP_BENEFITS } from "@/data/constants/vip-benefits";
import type { SidebarUserState } from "./user-header";
import styles from "./vip-benefits-card.module.css";
export interface VipBenefitsCardProps {
state: SidebarUserState;
onActivate: () => void;
}
/**
* VIP Membership Benefits 卡片
*
* 视觉规格(与设计稿对齐):
* - 白色卡片 + 粉色→品红渐变描边
* - 标题粉色加粗,右上角条件渲染 "Activated" pill(仅 VIP
* - 1./2./3./4. 编号列表
* - 底部条件渲染 "Activate VIP Membership" 按钮(非 VIP 时显示)
*
* 状态差异:
* - guest / member → 无状态 pill + 显示 Activate 按钮
* - vip → 显示 Activated pill + 无按钮
*/
export function VipBenefitsCard({ state, onActivate }: VipBenefitsCardProps) {
const isVip = state === "vip";
return (
<section className={styles.card}>
<header className={styles.header}>
<h2 className={styles.title}>VIP Membership Benefits</h2>
{isVip && (
<span className={styles.statusPill} aria-label="Activated">
Activated
</span>
)}
</header>
<ol className={styles.list}>
{VIP_BENEFITS.map((item, idx) => (
<li key={idx} className={styles.item}>
<span className={styles.numeral}>{idx + 1}.</span>
<span className={styles.text}>{item}</span>
</li>
))}
</ol>
{!isVip && (
<div className={styles.footer}>
<button
type="button"
className={styles.activateBtn}
onClick={onActivate}
aria-label="Activate VIP Membership"
>
Activate VIP Membership
</button>
</div>
)}
</section>
);
}
+25 -74
View File
@@ -1,6 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { Download, LogOut } from "lucide-react";
import { signOut } from "next-auth/react";
@@ -10,61 +9,23 @@ import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import { pwaUtil } from "@/utils";
import {
SidebarWalletCard,
UserHeader,
type SidebarUserState,
} from "./components";
import { SidebarWalletCard, UserHeader } from "./components";
import { getSidebarViewModel } from "./sidebar-view-model";
import { usePwaInstallEntry } from "./use-pwa-install-entry";
import { useSidebarUserBootstrap } from "./use-sidebar-user-bootstrap";
import styles from "./components/sidebar-screen.module.css";
const FALLBACK_USERNAME = "User name";
export function SidebarScreen() {
const navigator = useAppNavigator();
const user = useUserState();
const userDispatch = useUserDispatch();
const auth = useAuthState();
const authDispatch = useAuthDispatch();
const [canShowInstallApp, setCanShowInstallApp] = useState(false);
const pwaInstallEntry = usePwaInstallEntry();
useEffect(() => {
if (!auth.hasInitialized || auth.isLoading) return;
if (auth.loginStatus === "notLoggedIn") return;
userDispatch({ type: "UserFetch" });
}, [
auth.hasInitialized,
auth.isLoading,
auth.loginStatus,
userDispatch,
]);
useEffect(() => {
const updateInstallEntryVisibility = () => {
setCanShowInstallApp(pwaUtil.canShowInstallEntry());
};
pwaUtil.prepareInstallPrompt();
updateInstallEntryVisibility();
const unsubscribe = pwaUtil.subscribe(updateInstallEntryVisibility);
return () => {
unsubscribe();
};
}, []);
const handleInstallAppClick = async () => {
pwaUtil.prepareInstallPrompt();
const result = await pwaUtil.install();
setCanShowInstallApp(
result !== "accepted" &&
!pwaUtil.isInstalled() &&
pwaUtil.canShowInstallEntry(),
);
};
useSidebarUserBootstrap({ auth, userDispatch });
const handleLogoutClick = () => {
userDispatch({ type: "UserClearLocal" });
@@ -73,19 +34,11 @@ export function SidebarScreen() {
navigator.replace(ROUTES.splash, { scroll: false });
};
// 状态派生:未登录 / 已登录未 VIP / 已登录 VIP
const sidebarState: SidebarUserState =
auth.loginStatus === "notLoggedIn"
? "guest"
: user.currentUser?.isVip === true
? "vip"
: "member";
const view = getSidebarViewModel({
loginStatus: auth.loginStatus,
user,
});
const isInitializingUser =
sidebarState !== "guest" && user.currentUser == null;
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.avatarUrl;
return (
<MobileShell background="#fcf3f4">
<div className={styles.shell}>
@@ -98,42 +51,40 @@ export function SidebarScreen() {
<section className={`${styles.userSlot} ${styles.revealOne}`}>
<UserHeader
state={sidebarState}
name={name}
avatarUrl={avatarUrl}
isLoading={isInitializingUser}
state={view.state}
name={view.name}
avatarUrl={view.avatarUrl}
isLoading={view.isInitializingUser}
onLoginClick={() => navigator.openAuth(ROUTES.sidebar)}
/>
</section>
<section className={`${styles.cardSlot} ${styles.revealTwo}`}>
<SidebarWalletCard
creditBalance={user.creditBalance}
dailyFreeChatLimit={user.currentUser?.dailyFreeChatLimit}
dailyFreeChatRemaining={user.currentUser?.dailyFreeChatRemaining}
dailyFreePrivateLimit={user.currentUser?.dailyFreePrivateLimit}
dailyFreePrivateRemaining={
user.currentUser?.dailyFreePrivateRemaining
}
creditBalance={view.wallet.creditBalance}
dailyFreeChatLimit={view.wallet.dailyFreeChatLimit}
dailyFreeChatRemaining={view.wallet.dailyFreeChatRemaining}
dailyFreePrivateLimit={view.wallet.dailyFreePrivateLimit}
dailyFreePrivateRemaining={view.wallet.dailyFreePrivateRemaining}
onActivateVip={
sidebarState === "vip"
? undefined
: () => navigator.openSubscription({ type: "vip" })
view.canActivateVip
? () => navigator.openSubscription({ type: "vip" })
: undefined
}
onTopUp={() => navigator.openSubscription({ type: "topup" })}
onRulesClick={() => navigator.push(ROUTES.coinsRules)}
/>
</section>
{sidebarState !== "guest" ? (
{view.canShowSettings ? (
<section className={`${styles.settingsSlot} ${styles.revealThree}`}>
<p className={styles.settingsLabel}>Other</p>
<div className={styles.settingsActions}>
{canShowInstallApp ? (
{pwaInstallEntry.canInstall ? (
<button
type="button"
className={`${styles.logoutCard} ${styles.installCard}`}
onClick={() => void handleInstallAppClick()}
onClick={() => void pwaInstallEntry.installApp()}
>
<span
className={`${styles.logoutIcon} ${styles.installIcon}`}
+83
View File
@@ -0,0 +1,83 @@
import type { LoginStatus } from "@/data/dto/auth";
import type { UserView } from "@/data/dto/user";
export type SidebarUserState = "guest" | "member" | "vip";
export const SIDEBAR_FALLBACK_USERNAME = "User name";
export interface SidebarUserInput {
currentUser: UserView | null;
avatarUrl: string | null;
creditBalance?: number | null;
}
export interface SidebarWalletView {
creditBalance: number;
dailyFreeChatLimit: number;
dailyFreeChatRemaining: number;
dailyFreePrivateLimit: number;
dailyFreePrivateRemaining: number;
}
export interface SidebarViewModel {
state: SidebarUserState;
name: string;
avatarUrl: string | null;
isInitializingUser: boolean;
wallet: SidebarWalletView;
canActivateVip: boolean;
canShowSettings: boolean;
}
export function getSidebarViewModel({
loginStatus,
user,
}: {
loginStatus: LoginStatus;
user: SidebarUserInput;
}): SidebarViewModel {
const state = getSidebarUserState(loginStatus, user.currentUser);
return {
state,
name: user.currentUser?.username ?? SIDEBAR_FALLBACK_USERNAME,
avatarUrl: user.avatarUrl,
isInitializingUser: state !== "guest" && user.currentUser == null,
wallet: getSidebarWalletView(user),
canActivateVip: state !== "vip",
canShowSettings: state !== "guest",
};
}
export function getSidebarUserState(
loginStatus: LoginStatus,
currentUser: UserView | null,
): SidebarUserState {
if (loginStatus === "notLoggedIn") return "guest";
return currentUser?.isVip === true ? "vip" : "member";
}
export function getSidebarWalletView(
user: SidebarUserInput,
): SidebarWalletView {
const currentUser = user.currentUser;
return {
creditBalance: normalizeSidebarCount(user.creditBalance),
dailyFreeChatLimit: normalizeSidebarCount(currentUser?.dailyFreeChatLimit),
dailyFreeChatRemaining: normalizeSidebarCount(
currentUser?.dailyFreeChatRemaining,
),
dailyFreePrivateLimit: normalizeSidebarCount(
currentUser?.dailyFreePrivateLimit,
),
dailyFreePrivateRemaining: normalizeSidebarCount(
currentUser?.dailyFreePrivateRemaining,
),
};
}
export function normalizeSidebarCount(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import { pwaUtil } from "@/utils";
type PwaInstallResult = Awaited<ReturnType<typeof pwaUtil.install>>;
export function usePwaInstallEntry() {
const [canInstall, setCanInstall] = useState(false);
useEffect(() => {
const updateInstallEntryVisibility = () => {
setCanInstall(pwaUtil.canShowInstallEntry());
};
pwaUtil.prepareInstallPrompt();
updateInstallEntryVisibility();
const unsubscribe = pwaUtil.subscribe(updateInstallEntryVisibility);
return () => {
unsubscribe();
};
}, []);
const installApp = async () => {
pwaUtil.prepareInstallPrompt();
const result = await pwaUtil.install();
setCanInstall(
shouldKeepPwaInstallEntryVisible({
result,
isInstalled: pwaUtil.isInstalled(),
canShowInstallEntry: pwaUtil.canShowInstallEntry(),
}),
);
};
return {
canInstall,
installApp,
};
}
export function shouldKeepPwaInstallEntryVisible({
result,
isInstalled,
canShowInstallEntry,
}: {
result: PwaInstallResult;
isInstalled: boolean;
canShowInstallEntry: boolean;
}): boolean {
return result !== "accepted" && !isInstalled && canShowInstallEntry;
}
@@ -0,0 +1,31 @@
"use client";
import { type Dispatch, useEffect } from "react";
import type { LoginStatus } from "@/data/dto/auth";
interface SidebarAuthBootstrapState {
hasInitialized: boolean;
isLoading: boolean;
loginStatus: LoginStatus;
}
export function useSidebarUserBootstrap({
auth,
userDispatch,
}: {
auth: SidebarAuthBootstrapState;
userDispatch: Dispatch<{ type: "UserFetch" }>;
}) {
useEffect(() => {
if (!auth.hasInitialized || auth.isLoading) return;
if (auth.loginStatus === "notLoggedIn") return;
userDispatch({ type: "UserFetch" });
}, [
auth.hasInitialized,
auth.isLoading,
auth.loginStatus,
userDispatch,
]);
}
-12
View File
@@ -1,12 +0,0 @@
/**
* VIP 会员权益文案
*
* 共享常量:侧边栏 (`/sidebar`) 与订阅页 (`/subscription`) 都引用,
* 保持两处文案一致。
*/
export const VIP_BENEFITS: readonly string[] = [
"Unlimited chat sessions per day",
"Unlimited free access to private messages",
"Unlimited access to AI boyfriend photos",
"Free voice chat for X minutes",
] as const;