feat(profile)!: replace sidebar and add avatar navigation

Rename the global Sidebar route, UI, assets, analytics, and payment return context to Profile. Add accessible message-avatar navigation and preserve the source character across auth and logout flows.

BREAKING CHANGE: /sidebar has been removed; use /profile instead.
This commit is contained in:
2026-07-21 18:08:12 +08:00
parent 6cd3a0c2d2
commit 9deb320cf6
67 changed files with 565 additions and 256 deletions
@@ -0,0 +1,122 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProfileScreen } from "../profile-screen";
const mocks = vi.hoisted(() => ({
openAuth: vi.fn(),
openSubscription: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
authDispatch: vi.fn(),
userDispatch: vi.fn(),
}));
vi.mock("@/router/use-global-app-navigator", () => ({
useGlobalAppNavigator: () => ({
openAuth: mocks.openAuth,
openSubscription: mocks.openSubscription,
push: mocks.push,
replace: mocks.replace,
}),
}));
vi.mock("@/stores/auth/auth-context", () => ({
useAuthDispatch: () => mocks.authDispatch,
useAuthState: () => ({
hasInitialized: true,
isLoading: false,
loginStatus: "email",
}),
}));
vi.mock("@/stores/user/user-context", () => ({
useUserDispatch: () => mocks.userDispatch,
useUserState: () => ({
avatarUrl: null,
creditBalance: 120,
currentUser: {
id: "user-1",
username: "Chase",
countryCode: "US",
creditBalance: 120,
dailyFreeChatLimit: 30,
dailyFreeChatRemaining: 20,
dailyFreePrivateLimit: 2,
dailyFreePrivateRemaining: 1,
isVip: false,
},
}),
}));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
vi.mock("../use-pwa-install-entry", () => ({
usePwaInstallEntry: () => ({
canInstall: false,
installApp: vi.fn(),
}),
}));
vi.mock("../use-profile-user-bootstrap", () => ({
useProfileUserBootstrap: vi.fn(),
}));
describe("ProfileScreen", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
Object.values(mocks).forEach((mock) => mock.mockReset());
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders without CharacterProvider and preserves the source character", () => {
act(() => {
root.render(<ProfileScreen returnTo="/characters/maya/chat" />);
});
expect(
container.querySelector<HTMLAnchorElement>(
'[data-analytics-key="profile.back_to_chat"]',
)?.getAttribute("href"),
).toBe("/characters/maya/chat");
clickButton("Coin Usage Rules");
expect(mocks.push).toHaveBeenCalledWith(
"/coins-rules?returnTo=%2Fcharacters%2Fmaya%2Fchat",
);
clickButton("Feedback");
expect(mocks.push).toHaveBeenCalledWith(
"/feedback?returnTo=%2Fcharacters%2Fmaya%2Fchat",
);
clickButton("Top Up");
expect(mocks.openSubscription).toHaveBeenCalledWith(
expect.objectContaining({
type: "topup",
sourceCharacterSlug: "maya",
returnTo: "profile",
}),
);
});
function clickButton(label: string): void {
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes(label),
);
if (!button) throw new Error(`Missing ${label} button`);
act(() => button.click());
}
});
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import type { UserView } from "@/stores/user/user-view";
import {
getProfileViewModel,
getProfileWalletView,
normalizeProfileCount,
} from "../profile-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("getProfileViewModel", () => {
it("derives guest state for logged-out users", () => {
const view = getProfileViewModel({
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 = getProfileViewModel({
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 = getProfileViewModel({
loginStatus: "email",
user: {
currentUser: { ...baseUser, isVip: true },
avatarUrl: null,
creditBalance: 120,
},
});
expect(view.state).toBe("vip");
expect(view.canActivateVip).toBe(false);
});
it("keeps Guest users in the signed-out profile state", () => {
const view = getProfileViewModel({
loginStatus: "guest",
user: {
currentUser: null,
avatarUrl: null,
creditBalance: null,
},
});
expect(view.state).toBe("guest");
expect(view.name).toBe("User name");
expect(view.isInitializingUser).toBe(false);
});
});
describe("getProfileWalletView", () => {
it("normalizes missing, negative, and fractional wallet values", () => {
const view = getProfileWalletView({
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(normalizeProfileCount(undefined)).toBe(0);
expect(normalizeProfileCount(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);
});
});
@@ -0,0 +1,41 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UserHeader } from "../user-header";
describe("profile Tailwind components", () => {
it("renders guest, member, and VIP header states", () => {
const guestHtml = renderToStaticMarkup(
<UserHeader
state="guest"
name="User name"
avatarUrl={null}
onLoginClick={() => undefined}
/>,
);
const memberHtml = renderToStaticMarkup(
<UserHeader state="member" name="Chase" avatarUrl={null} />,
);
const vipHtml = renderToStaticMarkup(
<UserHeader state="vip" name="Chase" avatarUrl={null} />,
);
expect(guestHtml).toContain("flex w-full items-center");
expect(guestHtml).toContain('aria-label="Log in"');
expect(guestHtml).toContain("login");
expect(memberHtml).toContain("VIP membership not activated");
expect(vipHtml).toContain("VIP Member");
expect(vipHtml).toContain("rounded-full");
});
it("renders the loading skeleton with stable utility dimensions", () => {
const html = renderToStaticMarkup(
<UserHeader state="member" name="Chase" avatarUrl={null} isLoading />,
);
expect(html).toContain('aria-hidden="true"');
expect(html).toContain("w-[60%]");
expect(html).toContain("w-[80%]");
expect(html).toContain("w-[40%]");
});
});
+6
View File
@@ -0,0 +1,6 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./profile-wallet-card";
export * from "./user-header";
@@ -0,0 +1,240 @@
.shell {
position: relative;
display: flex;
flex-direction: column;
gap: var(--page-section-gap, 14px);
overflow: hidden;
min-height: var(--app-viewport-height, 100dvh);
box-sizing: border-box;
padding:
var(--app-safe-top, 0px)
calc(var(--page-padding-x, 28px) + var(--app-safe-right, 0px))
calc(var(--page-padding-y, 20px) + var(--app-safe-bottom, 0px))
calc(var(--page-padding-x, 28px) + var(--app-safe-left, 0px));
background:
radial-gradient(circle at 18% 6%, rgba(255, 206, 160, 0.22), transparent 28%),
radial-gradient(circle at 92% 0%, rgba(246, 87, 160, 0.22), transparent 30%),
linear-gradient(180deg, #fcf3f4 0%, #fffafa 44%, #ffffff 100%);
color: #191316;
}
.bgOrbOne,
.bgOrbTwo {
position: absolute;
z-index: 0;
pointer-events: none;
border-radius: 999px;
filter: blur(4px);
}
.bgOrbOne {
top: clamp(72px, 15.926vw, 86px);
right: clamp(-72px, -13.333vw, -58px);
width: clamp(140px, 31.111vw, 168px);
height: clamp(140px, 31.111vw, 168px);
background: rgba(246, 87, 160, 0.11);
}
.bgOrbTwo {
bottom: clamp(46px, 10.37vw, 56px);
left: clamp(-88px, -16.296vw, -70px);
width: clamp(148px, 33.333vw, 180px);
height: clamp(148px, 33.333vw, 180px);
background: rgba(248, 184, 62, 0.1);
}
.topBar,
.userSlot,
.cardSlot,
.settingsSlot {
position: relative;
z-index: 1;
}
.topBar {
margin: var(--page-padding-y, 18px) 0 -2px;
}
.userSlot {
padding: var(--responsive-card-padding, 18px);
border: 1px solid rgba(25, 19, 22, 0.06);
border-radius: var(--responsive-card-radius, 26px);
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 16px 40px rgba(55, 36, 44, 0.08);
backdrop-filter: blur(18px);
}
.cardSlot {
padding: 0;
}
.settingsSlot {
margin-top: 2px;
padding: 2px 0 0;
}
.settingsLabel {
margin: 0 0 clamp(7px, 1.667vw, 9px);
padding: 0 clamp(3px, 0.741vw, 4px);
color: #817076;
font-size: var(--responsive-caption, 13px);
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.settingsActions {
display: flex;
flex-direction: column;
gap: clamp(10px, 2.222vw, 12px);
}
.logoutCard {
display: flex;
width: 100%;
align-items: center;
gap: var(--spacing-md, 12px);
box-sizing: border-box;
padding: var(--responsive-card-padding, 16px);
border: 1px solid rgba(25, 19, 22, 0.06);
border-radius: var(--responsive-card-radius-sm, 22px);
background: rgba(255, 255, 255, 0.86);
color: #171114;
cursor: pointer;
font: inherit;
text-align: left;
box-shadow: 0 12px 30px rgba(55, 36, 44, 0.06);
transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.16s ease;
}
.installCard {
border-color: rgba(246, 87, 160, 0.14);
background: rgba(255, 255, 255, 0.9);
}
.feedbackCard {
border-color: rgba(208, 150, 91, 0.16);
background: rgba(255, 253, 250, 0.92);
}
.logoutCard:hover {
background: #ffffff;
box-shadow: 0 16px 34px rgba(55, 36, 44, 0.1);
transform: translateY(-1px);
}
.logoutCard:active {
transform: translateY(1px);
}
.logoutCard:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 3px;
}
.logoutIcon {
display: inline-flex;
width: var(--responsive-icon-button-size, 40px);
height: var(--responsive-icon-button-size, 40px);
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: clamp(13px, 2.778vw, 15px);
background: rgba(255, 107, 107, 0.1);
color: #ef4d64;
}
.installIcon {
background: rgba(246, 87, 160, 0.12);
color: #f657a0;
}
.feedbackIcon {
background: rgba(208, 150, 91, 0.13);
color: #b8783f;
}
.logoutText {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.logoutTitle {
color: #ef4d64;
font-size: var(--responsive-body, 16px);
font-weight: 760;
line-height: 1.15;
}
.installTitle {
color: #171114;
font-size: var(--responsive-body, 16px);
font-weight: 760;
line-height: 1.15;
}
.feedbackTitle {
color: #171114;
font-size: var(--responsive-body, 16px);
font-weight: 760;
line-height: 1.15;
}
.logoutSubtitle {
color: #817076;
font-size: var(--responsive-caption, 13px);
font-weight: 620;
line-height: 1.25;
}
.installSubtitle {
color: #817076;
font-size: var(--responsive-caption, 13px);
font-weight: 620;
line-height: 1.25;
}
.revealOne,
.revealTwo,
.revealThree {
animation: revealUp 0.42s ease both;
}
.revealOne {
animation-delay: 40ms;
}
.revealTwo {
animation-delay: 95ms;
}
.revealThree {
animation-delay: 150ms;
}
@keyframes revealUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 360px) {
.shell {
gap: var(--page-section-gap, 12px);
padding-right: calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px));
padding-left: calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px));
}
.userSlot {
padding: var(--responsive-card-padding, 16px);
border-radius: var(--responsive-card-radius-sm, 22px);
}
}
@@ -0,0 +1,270 @@
.card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--page-section-gap, 18px);
align-items: center;
width: 100%;
box-sizing: border-box;
padding: var(--responsive-card-padding, 18px);
border: 1px solid rgba(246, 87, 160, 0.16);
border-radius: var(--responsive-card-radius, 24px);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 239, 246, 0.94) 100%),
#ffffff;
box-shadow: 0 16px 38px rgba(55, 36, 44, 0.08);
overflow: hidden;
}
.leftColumn {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: clamp(7px, 1.667vw, 9px);
}
.titleRow {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: var(--spacing-sm, 8px);
}
.iconWrap {
display: inline-flex;
width: clamp(28px, 5.556vw, 30px);
height: clamp(28px, 5.556vw, 30px);
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 999px;
background: linear-gradient(180deg, #ffe89c 0%, #f8b83e 100%);
color: #8f5400;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.75),
0 8px 18px rgba(216, 132, 22, 0.2);
}
.title {
margin: 0;
color: #191316;
font-size: var(--responsive-card-title, 18px);
font-weight: 800;
line-height: 1.15;
}
.balance {
margin: 0;
color: #21191d;
line-height: 1;
}
.balanceValue {
font-size: var(--responsive-page-title, 22px);
font-weight: 850;
letter-spacing: -0.4px;
}
.balanceUnit {
color: #4f4449;
font-size: var(--responsive-body, 16px);
font-weight: 750;
}
.rulesButton {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: clamp(26px, 5.185vw, 28px);
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: #2e6eea;
cursor: pointer;
font: inherit;
font-size: var(--responsive-body, 15px);
font-weight: 760;
line-height: 1.2;
text-align: left;
transition: color 0.16s ease, transform 0.16s ease;
}
.rulesButton:hover {
color: #174fbd;
}
.rulesButton:active {
transform: translateY(1px);
}
.actionColumn {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: clamp(7px, 1.667vw, 9px);
align-items: stretch;
}
.freeAllowance {
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md, 12px);
margin-top: clamp(2px, 0.741vw, 4px);
padding: clamp(10px, 2.222vw, 12px) clamp(12px, 2.963vw, 16px);
border: 1px solid rgba(246, 87, 160, 0.12);
border-radius: var(--responsive-card-radius-sm, 18px);
background: rgba(255, 255, 255, 0.72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78);
}
.freeAllowanceTitle {
margin: 0;
color: #6d5d64;
font-size: var(--responsive-caption, 13px);
font-weight: 820;
line-height: 1.2;
white-space: nowrap;
}
.freeAllowanceItems {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: clamp(8px, 1.852vw, 10px);
}
.freeAllowanceContent {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.freeAllowanceItem {
display: inline-flex;
align-items: baseline;
gap: 3px;
color: #51464b;
font-size: var(--responsive-caption, 13px);
font-weight: 720;
line-height: 1.2;
white-space: nowrap;
}
.freeAllowanceItem strong {
color: #f657a0;
font-size: var(--responsive-body, 16px);
font-weight: 900;
}
.dailyFreeLimit {
margin: 0;
color: rgba(81, 70, 75, 0.68);
font-size: var(--responsive-caption, 12px);
font-weight: 720;
line-height: 1.2;
text-align: right;
}
.activateButton,
.topUpButton {
display: inline-flex;
min-width: clamp(78px, 17.037vw, 92px);
min-height: var(--responsive-control-height, 44px);
align-items: center;
justify-content: center;
padding: 0 var(--responsive-card-padding, 18px);
border: 1px solid transparent;
border-radius: var(--responsive-card-radius-sm, 18px);
cursor: pointer;
font: inherit;
font-size: var(--responsive-body, 15px);
font-weight: 850;
letter-spacing: 0.1px;
transition: box-shadow 0.18s ease, opacity 0.18s ease, transform 0.18s ease,
background 0.18s ease;
}
.activateButton {
background: #ffffff;
color: #f657a0;
border-color: rgba(246, 87, 160, 0.28);
box-shadow: 0 10px 22px rgba(246, 87, 160, 0.12);
}
.topUpButton {
background:
linear-gradient(135deg, #f657a0 0%, #ff7ac3 100%),
#f657a0;
color: #ffffff;
box-shadow: 0 12px 26px rgba(246, 87, 160, 0.28);
}
.activateButton:hover {
background: #fff5fa;
box-shadow: 0 14px 28px rgba(246, 87, 160, 0.18);
transform: translateY(-1px);
}
.topUpButton:hover {
box-shadow: 0 16px 30px rgba(246, 87, 160, 0.34);
transform: translateY(-1px);
}
.activateButton:active,
.topUpButton:active {
opacity: 0.92;
transform: translateY(1px);
}
.rulesButton:focus-visible,
.activateButton:focus-visible,
.topUpButton:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 3px;
}
@media (max-width: 360px) {
.card {
gap: var(--spacing-md, 12px);
padding: var(--responsive-card-padding, 16px);
}
.activateButton,
.topUpButton {
min-width: clamp(78px, 17.037vw, 92px);
min-height: var(--responsive-control-height, 40px);
padding: 0 clamp(12px, 2.593vw, 14px);
font-size: var(--responsive-body, 14px);
}
.balanceValue {
font-size: var(--responsive-page-title, 20px);
}
.balanceUnit,
.rulesButton {
font-size: var(--responsive-body, 14px);
}
.freeAllowance {
align-items: flex-start;
flex-direction: column;
}
.freeAllowanceItems {
justify-content: flex-start;
}
.freeAllowanceContent {
align-items: flex-start;
}
.dailyFreeLimit {
text-align: left;
}
}
@@ -0,0 +1,100 @@
"use client";
import { FaCoins } from "react-icons/fa6";
import styles from "./profile-wallet-card.module.css";
export interface ProfileWalletCardProps {
creditBalance: number;
dailyFreeChatLimit: number;
dailyFreeChatRemaining: number;
dailyFreePrivateLimit: number;
dailyFreePrivateRemaining: number;
onActivateVip?: () => void;
onTopUp: () => void;
onRulesClick: () => void;
}
const formatCoins = (value: number): string =>
new Intl.NumberFormat("en-US").format(Math.max(0, Math.trunc(value)));
export function ProfileWalletCard({
creditBalance,
dailyFreeChatLimit,
dailyFreeChatRemaining,
dailyFreePrivateLimit,
dailyFreePrivateRemaining,
onActivateVip,
onTopUp,
onRulesClick,
}: ProfileWalletCardProps) {
return (
<section className={styles.card} aria-label="My wallet">
<div className={styles.leftColumn}>
<div className={styles.titleRow}>
<span className={styles.iconWrap} aria-hidden="true">
<FaCoins size={18} />
</span>
<h2 className={styles.title}>My Wallet</h2>
</div>
<p className={styles.balance}>
<span className={styles.balanceValue}>{formatCoins(creditBalance)}</span>
<span className={styles.balanceUnit}> coins</span>
</p>
<button
type="button"
data-analytics-key="profile.coins_rules"
data-analytics-label="Open coin usage rules"
className={styles.rulesButton}
onClick={onRulesClick}
>
Coin Usage Rules
</button>
</div>
<div className={styles.actionColumn}>
{onActivateVip ? (
<button
type="button"
data-analytics-key="profile.activate_vip"
data-analytics-label="Activate VIP"
className={styles.activateButton}
onClick={onActivateVip}
>
Activate VIP
</button>
) : null}
<button
type="button"
data-analytics-key="profile.topup"
data-analytics-label="Top up credits"
className={styles.topUpButton}
onClick={onTopUp}
>
Top Up
</button>
</div>
<div className={styles.freeAllowance} aria-label="Free allowance">
<p className={styles.freeAllowanceTitle}>Free Allowance</p>
<div className={styles.freeAllowanceContent}>
<div className={styles.freeAllowanceItems}>
<span className={styles.freeAllowanceItem}>
<strong>{dailyFreeChatRemaining}</strong>
<span> chats left</span>
</span>
<span className={styles.freeAllowanceItem}>
<strong>{dailyFreePrivateRemaining}</strong>
<span> private left</span>
</span>
</div>
<p className={styles.dailyFreeLimit}>
Daily free: {dailyFreeChatLimit} chats / {dailyFreePrivateLimit} private
</p>
</div>
</div>
</section>
);
}
@@ -0,0 +1,94 @@
"use client";
import Image from "next/image";
import { UserMessageAvatar } from "@/app/_components";
import type { ProfileUserState } from "@/app/profile/profile-view-model";
export interface UserHeaderProps {
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
state: ProfileUserState;
/** 用户名(无 currentUser 时使用 fallback "User name" */
name: string;
/** null → 默认头像 SVG */
avatarUrl: string | null;
/** 当前是否正在加载(currentUser 尚未就绪) */
isLoading?: boolean;
/** guest 状态下点击 "login" pill 触发 */
onLoginClick?: () => void;
}
/**
* 用户信息行
*
* - guest:仅显示 "User name" + 右侧 "login" 胶囊
* - member:显示 "User name" + 灰色副标题 "VIP membership not activated"
* - vip:显示 "User name" + 粉色 "VIP Member" 胶囊(带小钻石图标)
* - isLoading:渲染 3 条骨架占位
*/
export function UserHeader({
state,
name,
avatarUrl,
isLoading = false,
onLoginClick,
}: UserHeaderProps) {
if (isLoading) {
return (
<div className="flex w-full items-center gap-(--spacing-md,12px)" aria-hidden="true">
<div className="flex size-(--profile-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white" />
<div className="flex min-w-0 flex-auto flex-col gap-(--profile-card-gap-y,4px)">
<span className="block h-[clamp(16px,3.333vw,18px)] w-[60%] rounded-md bg-(--color-dark-gray,#e5e5e5)" />
<span className="block h-[clamp(12px,2.593vw,14px)] w-[80%] rounded-md bg-(--color-dark-gray,#e5e5e5)" />
<span className="block h-[clamp(12px,2.593vw,14px)] w-[40%] rounded-md bg-(--color-dark-gray,#e5e5e5)" />
</div>
</div>
);
}
return (
<div className="flex w-full items-center gap-(--spacing-md,12px)">
<UserMessageAvatar
avatarUrl={avatarUrl}
className="flex size-(--profile-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-accent text-white"
size="var(--profile-avatar-size, 56px)"
/>
<div className="flex min-w-0 flex-auto flex-col gap-(--profile-card-gap-y,4px)">
<p className="m-0 overflow-hidden text-ellipsis whitespace-nowrap text-(length:--font-responsive-md,16px) font-semibold leading-[1.2] text-black">
{name}
</p>
{state === "member" && (
<p className="m-0 text-(length:clamp(12px,2.593vw,14px)) leading-[1.2] text-(--color-text-secondary,#9e9e9e)">
VIP membership not activated
</p>
)}
{state === "vip" && (
<span className="inline-flex self-start items-center gap-(--spacing-xs,4px) rounded-full bg-(--color-pill-bg,rgba(248,77,150,0.10)) px-[clamp(7px,1.667vw,9px)] py-[clamp(3px,0.741vw,4px)] text-(length:--font-size-xs,10px) font-semibold leading-none text-accent">
<Image
src="/images/profile/ic_user_vip.png"
alt=""
width={39}
height={36}
className="block h-[clamp(11px,2.407vw,13px)] w-[clamp(12px,2.593vw,14px)] shrink-0"
/>
<span>VIP Member</span>
</span>
)}
</div>
{state === "guest" && (
<button
type="button"
data-analytics-key="profile.login"
data-analytics-label="Open login"
className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[linear-gradient(90deg,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] px-(--spacing-lg,16px) py-[clamp(5px,1.111vw,6px)] text-(length:clamp(12px,2.593vw,14px)) font-semibold text-(--color-text-primary,#ffffff) hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
onClick={onLoginClick}
aria-label="Log in"
>
login
</button>
)}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { RouteSearchParams } from "@/router/routes";
import { ProfileScreen } from "./profile-screen";
export default async function ProfilePage({
searchParams,
}: {
searchParams: Promise<RouteSearchParams>;
}) {
const query = await searchParams;
return <ProfileScreen returnTo={query.returnTo} />;
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { Download, LogOut, MessageCircleQuestion } from "lucide-react";
import { signOut } from "next-auth/react";
import { BackButton } from "@/app/_components";
import { MobileShell } from "@/app/_components/core";
import {
resolveGlobalRouteContext,
type GlobalReturnToValue,
} from "@/router/global-route-context";
import { setPendingLogoutNavigation } from "@/router/logout-navigation";
import { useGlobalAppNavigator } from "@/router/use-global-app-navigator";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
import { ProfileWalletCard, UserHeader } from "./components";
import { getProfileViewModel } from "./profile-view-model";
import { usePwaInstallEntry } from "./use-pwa-install-entry";
import { useProfileUserBootstrap } from "./use-profile-user-bootstrap";
import styles from "./components/profile-screen.module.css";
export interface ProfileScreenProps {
returnTo?: GlobalReturnToValue;
}
export function ProfileScreen({ returnTo }: ProfileScreenProps) {
const navigator = useGlobalAppNavigator();
const navigation = resolveGlobalRouteContext(returnTo);
const user = useUserState();
const userDispatch = useUserDispatch();
const auth = useAuthState();
const authDispatch = useAuthDispatch();
const pwaInstallEntry = usePwaInstallEntry();
useProfileUserBootstrap({ auth, userDispatch });
const handleLogoutClick = () => {
setPendingLogoutNavigation(navigation.splashUrl);
userDispatch({ type: "UserClearLocal" });
authDispatch({ type: "AuthLogoutSubmitted" });
void signOut({ redirect: false });
navigator.replace(navigation.splashUrl, { scroll: false });
};
const view = getProfileViewModel({
loginStatus: auth.loginStatus,
user,
});
return (
<MobileShell background="#fcf3f4">
<div className={styles.shell}>
<div className={styles.bgOrbOne} aria-hidden="true" />
<div className={styles.bgOrbTwo} aria-hidden="true" />
<div className={styles.topBar}>
<BackButton
href={navigation.chatUrl}
variant="soft"
analyticsKey="profile.back_to_chat"
/>
</div>
<section className={`${styles.userSlot} ${styles.revealOne}`}>
<UserHeader
state={view.state}
name={view.name}
avatarUrl={view.avatarUrl}
isLoading={view.isInitializingUser}
onLoginClick={() => navigator.openAuth(navigation.profileUrl)}
/>
</section>
<section className={`${styles.cardSlot} ${styles.revealTwo}`}>
<ProfileWalletCard
creditBalance={view.wallet.creditBalance}
dailyFreeChatLimit={view.wallet.dailyFreeChatLimit}
dailyFreeChatRemaining={view.wallet.dailyFreeChatRemaining}
dailyFreePrivateLimit={view.wallet.dailyFreePrivateLimit}
dailyFreePrivateRemaining={view.wallet.dailyFreePrivateRemaining}
onActivateVip={
view.canActivateVip
? () =>
navigator.openSubscription({
type: "vip",
sourceCharacterSlug: navigation.characterSlug,
returnTo: "profile",
analytics: {
entryPoint: "profile",
triggerReason: "vip_cta",
},
})
: undefined
}
onTopUp={() =>
navigator.openSubscription({
type: "topup",
sourceCharacterSlug: navigation.characterSlug,
returnTo: "profile",
analytics: {
entryPoint: "profile",
triggerReason: "profile_recharge",
},
})
}
onRulesClick={() => navigator.push(navigation.coinsRulesUrl)}
/>
</section>
{view.canShowSettings ? (
<section className={`${styles.settingsSlot} ${styles.revealThree}`}>
<p className={styles.settingsLabel}>Other</p>
<div className={styles.settingsActions}>
<button
type="button"
data-analytics-key="profile.open_feedback"
data-analytics-label="Open feedback"
className={`${styles.logoutCard} ${styles.feedbackCard}`}
onClick={() => navigator.push(navigation.feedbackUrl)}
>
<span
className={`${styles.logoutIcon} ${styles.feedbackIcon}`}
aria-hidden="true"
>
<MessageCircleQuestion size={19} strokeWidth={2.2} />
</span>
<span className={styles.logoutText}>
<span className={styles.feedbackTitle}>Feedback</span>
<span className={styles.logoutSubtitle}>
Report a problem or share an idea
</span>
</span>
</button>
{pwaInstallEntry.canInstall ? (
<button
type="button"
className={`${styles.logoutCard} ${styles.installCard}`}
onClick={() => void pwaInstallEntry.installApp()}
>
<span
className={`${styles.logoutIcon} ${styles.installIcon}`}
aria-hidden="true"
>
<Download size={19} strokeWidth={2.2} />
</span>
<span className={styles.logoutText}>
<span className={styles.installTitle}>Install app</span>
<span className={styles.installSubtitle}>
Add CozSweet to your Home Screen
</span>
</span>
</button>
) : null}
<button
type="button"
data-analytics-key="profile.logout"
data-analytics-label="Log out"
className={styles.logoutCard}
onClick={handleLogoutClick}
>
<span className={styles.logoutIcon} aria-hidden="true">
<LogOut size={19} strokeWidth={2.2} />
</span>
<span className={styles.logoutText}>
<span className={styles.logoutTitle}>Log out</span>
<span className={styles.logoutSubtitle}>
End the current session safely
</span>
</span>
</button>
</div>
</section>
) : null}
</div>
</MobileShell>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { LoginStatus } from "@/data/schemas/auth";
import type { UserView } from "@/stores/user/user-view";
export type ProfileUserState = "guest" | "member" | "vip";
export const PROFILE_FALLBACK_USERNAME = "User name";
export interface ProfileUserInput {
currentUser: UserView | null;
avatarUrl: string | null;
creditBalance?: number | null;
}
export interface ProfileWalletView {
creditBalance: number;
dailyFreeChatLimit: number;
dailyFreeChatRemaining: number;
dailyFreePrivateLimit: number;
dailyFreePrivateRemaining: number;
}
export interface ProfileViewModel {
state: ProfileUserState;
name: string;
avatarUrl: string | null;
isInitializingUser: boolean;
wallet: ProfileWalletView;
canActivateVip: boolean;
canShowSettings: boolean;
}
export function getProfileViewModel({
loginStatus,
user,
}: {
loginStatus: LoginStatus;
user: ProfileUserInput;
}): ProfileViewModel {
const state = getProfileUserState(loginStatus, user.currentUser);
return {
state,
name: user.currentUser?.username ?? PROFILE_FALLBACK_USERNAME,
avatarUrl: user.avatarUrl,
isInitializingUser: state !== "guest" && user.currentUser == null,
wallet: getProfileWalletView(user),
canActivateVip: state !== "vip",
canShowSettings: state !== "guest",
};
}
export function getProfileUserState(
loginStatus: LoginStatus,
currentUser: UserView | null,
): ProfileUserState {
if (loginStatus === "notLoggedIn" || loginStatus === "guest") {
return "guest";
}
return currentUser?.isVip === true ? "vip" : "member";
}
export function getProfileWalletView(
user: ProfileUserInput,
): ProfileWalletView {
const currentUser = user.currentUser;
return {
creditBalance: normalizeProfileCount(user.creditBalance),
dailyFreeChatLimit: normalizeProfileCount(currentUser?.dailyFreeChatLimit),
dailyFreeChatRemaining: normalizeProfileCount(
currentUser?.dailyFreeChatRemaining,
),
dailyFreePrivateLimit: normalizeProfileCount(
currentUser?.dailyFreePrivateLimit,
),
dailyFreePrivateRemaining: normalizeProfileCount(
currentUser?.dailyFreePrivateRemaining,
),
};
}
export function normalizeProfileCount(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
@@ -0,0 +1,31 @@
"use client";
import { type Dispatch, useEffect } from "react";
import type { LoginStatus } from "@/data/schemas/auth";
interface ProfileAuthBootstrapState {
hasInitialized: boolean;
isLoading: boolean;
loginStatus: LoginStatus;
}
export function useProfileUserBootstrap({
auth,
userDispatch,
}: {
auth: ProfileAuthBootstrapState;
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,
]);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import { pwaUtil } from "@/utils/pwa";
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;
}