diff --git a/src/app/sidebar/__tests__/sidebar-view-model.test.ts b/src/app/sidebar/__tests__/sidebar-view-model.test.ts new file mode 100644 index 00000000..fc8796bd --- /dev/null +++ b/src/app/sidebar/__tests__/sidebar-view-model.test.ts @@ -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); + }); +}); diff --git a/src/app/sidebar/__tests__/use-pwa-install-entry.test.ts b/src/app/sidebar/__tests__/use-pwa-install-entry.test.ts new file mode 100644 index 00000000..b790fff0 --- /dev/null +++ b/src/app/sidebar/__tests__/use-pwa-install-entry.test.ts @@ -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); + }); +}); diff --git a/src/app/sidebar/components/index.ts b/src/app/sidebar/components/index.ts index fb685f6c..59b2aa44 100644 --- a/src/app/sidebar/components/index.ts +++ b/src/app/sidebar/components/index.ts @@ -4,4 +4,3 @@ export * from "./sidebar-wallet-card"; export * from "./user-header"; -export * from "./vip-benefits-card"; diff --git a/src/app/sidebar/components/sidebar-screen.module.css b/src/app/sidebar/components/sidebar-screen.module.css index b9ca512a..7012d34e 100644 --- a/src/app/sidebar/components/sidebar-screen.module.css +++ b/src/app/sidebar/components/sidebar-screen.module.css @@ -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); diff --git a/src/app/sidebar/components/sidebar-wallet-card.tsx b/src/app/sidebar/components/sidebar-wallet-card.tsx index 8c056223..cfe36943 100644 --- a/src/app/sidebar/components/sidebar-wallet-card.tsx +++ b/src/app/sidebar/components/sidebar-wallet-card.tsx @@ -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, diff --git a/src/app/sidebar/components/user-header.tsx b/src/app/sidebar/components/user-header.tsx index 8c9278fe..b715e91b 100644 --- a/src/app/sidebar/components/user-header.tsx +++ b/src/app/sidebar/components/user-header.tsx @@ -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; diff --git a/src/app/sidebar/components/vip-benefits-card.module.css b/src/app/sidebar/components/vip-benefits-card.module.css deleted file mode 100644 index ca33903a..00000000 --- a/src/app/sidebar/components/vip-benefits-card.module.css +++ /dev/null @@ -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; -} diff --git a/src/app/sidebar/components/vip-benefits-card.tsx b/src/app/sidebar/components/vip-benefits-card.tsx deleted file mode 100644 index 4de446c2..00000000 --- a/src/app/sidebar/components/vip-benefits-card.tsx +++ /dev/null @@ -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 ( -
-
-

VIP Membership Benefits

- {isVip && ( - - Activated - - )} -
- -
    - {VIP_BENEFITS.map((item, idx) => ( -
  1. - {idx + 1}. - {item} -
  2. - ))} -
- - {!isVip && ( -
- -
- )} -
- ); -} diff --git a/src/app/sidebar/sidebar-screen.tsx b/src/app/sidebar/sidebar-screen.tsx index 67ac54ea..e383f3c3 100644 --- a/src/app/sidebar/sidebar-screen.tsx +++ b/src/app/sidebar/sidebar-screen.tsx @@ -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 (
@@ -98,42 +51,40 @@ export function SidebarScreen() {
navigator.openAuth(ROUTES.sidebar)} />
navigator.openSubscription({ type: "vip" }) + view.canActivateVip + ? () => navigator.openSubscription({ type: "vip" }) + : undefined } onTopUp={() => navigator.openSubscription({ type: "topup" })} onRulesClick={() => navigator.push(ROUTES.coinsRules)} />
- {sidebarState !== "guest" ? ( + {view.canShowSettings ? (

Other

- {canShowInstallApp ? ( + {pwaInstallEntry.canInstall ? (