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 "./sidebar-wallet-card";
|
||||||
export * from "./user-header";
|
export * from "./user-header";
|
||||||
export * from "./vip-benefits-card";
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topBar,
|
.topBar,
|
||||||
.hero,
|
|
||||||
.userSlot,
|
.userSlot,
|
||||||
.cardSlot,
|
.cardSlot,
|
||||||
.settingsSlot {
|
.settingsSlot {
|
||||||
@@ -56,45 +55,6 @@
|
|||||||
margin: var(--page-padding-y, 18px) 0 -2px;
|
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 {
|
.userSlot {
|
||||||
padding: var(--responsive-card-padding, 18px);
|
padding: var(--responsive-card-padding, 18px);
|
||||||
border: 1px solid rgba(25, 19, 22, 0.06);
|
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));
|
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 {
|
.userSlot {
|
||||||
padding: var(--responsive-card-padding, 16px);
|
padding: var(--responsive-card-padding, 16px);
|
||||||
border-radius: var(--responsive-card-radius-sm, 22px);
|
border-radius: var(--responsive-card-radius-sm, 22px);
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import styles from "./sidebar-wallet-card.module.css";
|
|||||||
|
|
||||||
export interface SidebarWalletCardProps {
|
export interface SidebarWalletCardProps {
|
||||||
creditBalance: number;
|
creditBalance: number;
|
||||||
dailyFreeChatLimit?: number;
|
dailyFreeChatLimit: number;
|
||||||
dailyFreeChatRemaining?: number;
|
dailyFreeChatRemaining: number;
|
||||||
dailyFreePrivateLimit?: number;
|
dailyFreePrivateLimit: number;
|
||||||
dailyFreePrivateRemaining?: number;
|
dailyFreePrivateRemaining: number;
|
||||||
onActivateVip?: () => void;
|
onActivateVip?: () => void;
|
||||||
onTopUp: () => void;
|
onTopUp: () => void;
|
||||||
onRulesClick: () => void;
|
onRulesClick: () => void;
|
||||||
@@ -20,10 +20,10 @@ const formatCoins = (value: number): string =>
|
|||||||
|
|
||||||
export function SidebarWalletCard({
|
export function SidebarWalletCard({
|
||||||
creditBalance,
|
creditBalance,
|
||||||
dailyFreeChatLimit = 0,
|
dailyFreeChatLimit,
|
||||||
dailyFreeChatRemaining = 0,
|
dailyFreeChatRemaining,
|
||||||
dailyFreePrivateLimit = 0,
|
dailyFreePrivateLimit,
|
||||||
dailyFreePrivateRemaining = 0,
|
dailyFreePrivateRemaining,
|
||||||
onActivateVip,
|
onActivateVip,
|
||||||
onTopUp,
|
onTopUp,
|
||||||
onRulesClick,
|
onRulesClick,
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
import { UserMessageAvatar } from "@/app/_components";
|
import { UserMessageAvatar } from "@/app/_components";
|
||||||
|
import type { SidebarUserState } from "@/app/sidebar/sidebar-view-model";
|
||||||
|
|
||||||
import styles from "./user-header.module.css";
|
import styles from "./user-header.module.css";
|
||||||
|
|
||||||
export type SidebarUserState = "guest" | "member" | "vip";
|
|
||||||
|
|
||||||
export interface UserHeaderProps {
|
export interface UserHeaderProps {
|
||||||
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
|
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
|
||||||
state: SidebarUserState;
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Download, LogOut } from "lucide-react";
|
import { Download, LogOut } from "lucide-react";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
@@ -10,61 +9,23 @@ import { ROUTES } from "@/router/routes";
|
|||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
||||||
import { pwaUtil } from "@/utils";
|
|
||||||
|
|
||||||
import {
|
import { SidebarWalletCard, UserHeader } from "./components";
|
||||||
SidebarWalletCard,
|
import { getSidebarViewModel } from "./sidebar-view-model";
|
||||||
UserHeader,
|
import { usePwaInstallEntry } from "./use-pwa-install-entry";
|
||||||
type SidebarUserState,
|
import { useSidebarUserBootstrap } from "./use-sidebar-user-bootstrap";
|
||||||
} from "./components";
|
|
||||||
|
|
||||||
import styles from "./components/sidebar-screen.module.css";
|
import styles from "./components/sidebar-screen.module.css";
|
||||||
|
|
||||||
const FALLBACK_USERNAME = "User name";
|
|
||||||
|
|
||||||
export function SidebarScreen() {
|
export function SidebarScreen() {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
const userDispatch = useUserDispatch();
|
const userDispatch = useUserDispatch();
|
||||||
const auth = useAuthState();
|
const auth = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
const [canShowInstallApp, setCanShowInstallApp] = useState(false);
|
const pwaInstallEntry = usePwaInstallEntry();
|
||||||
|
|
||||||
useEffect(() => {
|
useSidebarUserBootstrap({ auth, userDispatch });
|
||||||
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(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogoutClick = () => {
|
const handleLogoutClick = () => {
|
||||||
userDispatch({ type: "UserClearLocal" });
|
userDispatch({ type: "UserClearLocal" });
|
||||||
@@ -73,19 +34,11 @@ export function SidebarScreen() {
|
|||||||
navigator.replace(ROUTES.splash, { scroll: false });
|
navigator.replace(ROUTES.splash, { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 状态派生:未登录 / 已登录未 VIP / 已登录 VIP
|
const view = getSidebarViewModel({
|
||||||
const sidebarState: SidebarUserState =
|
loginStatus: auth.loginStatus,
|
||||||
auth.loginStatus === "notLoggedIn"
|
user,
|
||||||
? "guest"
|
});
|
||||||
: user.currentUser?.isVip === true
|
|
||||||
? "vip"
|
|
||||||
: "member";
|
|
||||||
|
|
||||||
const isInitializingUser =
|
|
||||||
sidebarState !== "guest" && user.currentUser == null;
|
|
||||||
|
|
||||||
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
|
||||||
const avatarUrl = user.avatarUrl;
|
|
||||||
return (
|
return (
|
||||||
<MobileShell background="#fcf3f4">
|
<MobileShell background="#fcf3f4">
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
@@ -98,42 +51,40 @@ export function SidebarScreen() {
|
|||||||
|
|
||||||
<section className={`${styles.userSlot} ${styles.revealOne}`}>
|
<section className={`${styles.userSlot} ${styles.revealOne}`}>
|
||||||
<UserHeader
|
<UserHeader
|
||||||
state={sidebarState}
|
state={view.state}
|
||||||
name={name}
|
name={view.name}
|
||||||
avatarUrl={avatarUrl}
|
avatarUrl={view.avatarUrl}
|
||||||
isLoading={isInitializingUser}
|
isLoading={view.isInitializingUser}
|
||||||
onLoginClick={() => navigator.openAuth(ROUTES.sidebar)}
|
onLoginClick={() => navigator.openAuth(ROUTES.sidebar)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={`${styles.cardSlot} ${styles.revealTwo}`}>
|
<section className={`${styles.cardSlot} ${styles.revealTwo}`}>
|
||||||
<SidebarWalletCard
|
<SidebarWalletCard
|
||||||
creditBalance={user.creditBalance}
|
creditBalance={view.wallet.creditBalance}
|
||||||
dailyFreeChatLimit={user.currentUser?.dailyFreeChatLimit}
|
dailyFreeChatLimit={view.wallet.dailyFreeChatLimit}
|
||||||
dailyFreeChatRemaining={user.currentUser?.dailyFreeChatRemaining}
|
dailyFreeChatRemaining={view.wallet.dailyFreeChatRemaining}
|
||||||
dailyFreePrivateLimit={user.currentUser?.dailyFreePrivateLimit}
|
dailyFreePrivateLimit={view.wallet.dailyFreePrivateLimit}
|
||||||
dailyFreePrivateRemaining={
|
dailyFreePrivateRemaining={view.wallet.dailyFreePrivateRemaining}
|
||||||
user.currentUser?.dailyFreePrivateRemaining
|
|
||||||
}
|
|
||||||
onActivateVip={
|
onActivateVip={
|
||||||
sidebarState === "vip"
|
view.canActivateVip
|
||||||
? undefined
|
? () => navigator.openSubscription({ type: "vip" })
|
||||||
: () => navigator.openSubscription({ type: "vip" })
|
: undefined
|
||||||
}
|
}
|
||||||
onTopUp={() => navigator.openSubscription({ type: "topup" })}
|
onTopUp={() => navigator.openSubscription({ type: "topup" })}
|
||||||
onRulesClick={() => navigator.push(ROUTES.coinsRules)}
|
onRulesClick={() => navigator.push(ROUTES.coinsRules)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{sidebarState !== "guest" ? (
|
{view.canShowSettings ? (
|
||||||
<section className={`${styles.settingsSlot} ${styles.revealThree}`}>
|
<section className={`${styles.settingsSlot} ${styles.revealThree}`}>
|
||||||
<p className={styles.settingsLabel}>Other</p>
|
<p className={styles.settingsLabel}>Other</p>
|
||||||
<div className={styles.settingsActions}>
|
<div className={styles.settingsActions}>
|
||||||
{canShowInstallApp ? (
|
{pwaInstallEntry.canInstall ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.logoutCard} ${styles.installCard}`}
|
className={`${styles.logoutCard} ${styles.installCard}`}
|
||||||
onClick={() => void handleInstallAppClick()}
|
onClick={() => void pwaInstallEntry.installApp()}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`${styles.logoutIcon} ${styles.installIcon}`}
|
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