refactor(sidebar): split state derivation
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user