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:
@@ -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%]");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user