From 469512df185b093a424e5d6f57296fb1a688cff5 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 19:31:06 +0800 Subject: [PATCH] feat(navigation): add favorite entry and Menu tab --- .../mock/favorite-menu-navigation.spec.ts | 198 ++++++++++++++++++ .../__tests__/tailwind-components.test.tsx | 4 + .../core/app-bottom-nav.module.css | 2 +- src/app/_components/core/app-bottom-nav.tsx | 17 +- .../core/favorite-entry-button.module.css | 96 +++++++++ .../core/favorite-entry-button.tsx | 107 ++++++++++ src/app/_components/core/index.ts | 1 + src/app/chat/chat-screen.tsx | 9 +- .../__tests__/tailwind-components.test.tsx | 46 ++-- src/app/chat/components/chat-header.tsx | 68 ++---- .../first-recharge-offer-banner.module.css | 77 ++++++- .../first-recharge-offer-banner.tsx | 24 +++ .../external-entry/external-entry-persist.tsx | 10 + src/app/external-entry/page.tsx | 1 + .../private-zone-screen.module.css | 7 + src/app/private-zone/private-zone-screen.tsx | 20 +- .../components/profile-screen.module.css | 21 +- src/app/profile/profile-screen.tsx | 29 ++- .../components/splash-screen.module.css | 7 + src/app/splash/splash-screen.tsx | 22 +- src/lib/chat/chat_external_browser.ts | 33 +-- .../__tests__/favorite_entry.test.ts | 62 ++++++ src/lib/navigation/favorite_entry.ts | 51 +++++ 23 files changed, 794 insertions(+), 118 deletions(-) create mode 100644 e2e/specs/mock/favorite-menu-navigation.spec.ts create mode 100644 src/app/_components/core/favorite-entry-button.module.css create mode 100644 src/app/_components/core/favorite-entry-button.tsx create mode 100644 src/lib/navigation/__tests__/favorite_entry.test.ts create mode 100644 src/lib/navigation/favorite_entry.ts diff --git a/e2e/specs/mock/favorite-menu-navigation.spec.ts b/e2e/specs/mock/favorite-menu-navigation.spec.ts new file mode 100644 index 00000000..aa6a1708 --- /dev/null +++ b/e2e/specs/mock/favorite-menu-navigation.spec.ts @@ -0,0 +1,198 @@ +import path from "node:path"; + +import { expect, test, type Page } from "@playwright/test"; + +import { mockCoreApis } from "@e2e/fixtures/api-mocks"; +import { apiEnvelope } from "@e2e/fixtures/data/common"; +import { e2eEmailUser } from "@e2e/fixtures/data/auth"; +import { chatCharactersResponse } from "@e2e/fixtures/data/character"; +import { + emptyChatHistoryResponse, + emptyChatPreviewsResponse, +} from "@e2e/fixtures/data/chat"; +import { paymentPlansResponse } from "@e2e/fixtures/data/payment"; +import { userEntitlementsResponse } from "@e2e/fixtures/data/user"; +import { + defaultCharacterChatPath, + defaultCharacterSplashPath, + seedEmailSession, +} from "@e2e/fixtures/helpers/auth"; + +const previewDirectory = path.join( + process.cwd(), + ".codex", + "artifacts", + "frontend-preview", + "2026-07-22", + "favorite-menu", +); + +test.beforeEach(async ({ page }) => { + await registerFavoriteMenuMocks(page); +}); + +test("favorite entry and three-tab Menu navigation render on the real pages", async ({ + page, +}, testInfo) => { + const mobile = testInfo.project.name.includes("mobile"); + await page.setViewportSize( + mobile ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ); + const browserErrors = collectBrowserErrors(page); + + await seedEmailSession(page); + await page.goto(defaultCharacterChatPath); + + await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible(); + await expect( + page.getByRole("button", { name: /First recharge offer, 50% off/i }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Profile" })).toHaveCount(0); + await savePreview(page, `${mobile ? "mobile" : "desktop"}-chat.png`); + + await page.goto(defaultCharacterSplashPath); + await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + const navigation = page.getByRole("navigation", { + name: "Primary navigation", + }); + await expect(navigation).toContainText("Chat"); + await expect(navigation).toContainText("Elio Private Zoom"); + await expect(navigation).toContainText("Menu"); + await savePreview(page, `${mobile ? "mobile" : "desktop"}-splash.png`); + + await page.goto("/characters/elio/private-zoom"); + await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + await savePreview( + page, + `${mobile ? "mobile" : "desktop"}-private-zoom.png`, + ); + + await page.getByRole("button", { name: "Menu" }).click(); + await expect(page.getByRole("heading", { name: "Menu" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Save CozSweet" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Menu" })).toHaveAttribute( + "aria-current", + "page", + ); + await savePreview(page, `${mobile ? "mobile" : "desktop"}-menu.png`); + + if (mobile) { + await page.evaluate(() => { + localStorage.setItem("cozsweet:favorite_entry", "1"); + }); + await page.goto(defaultCharacterChatPath); + await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); + await savePreview(page, "mobile-android-external-chat-download.png"); + + await page.addInitScript(() => { + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", + }); + }); + await page.goto(defaultCharacterChatPath); + await expect(page.getByRole("button", { name: "Saved" })).toBeVisible(); + await savePreview(page, "mobile-ios-external-chat-saved.png"); + } + + expect(browserErrors.filter(isFeatureRuntimeError)).toEqual([]); +}); + +async function registerFavoriteMenuMocks(page: Page): Promise { + await mockCoreApis(page); + await page.route("**/api/payment/plans**", async (route) => { + await route.fulfill({ + json: apiEnvelope({ + ...paymentPlansResponse, + isFirstRecharge: true, + }), + }); + }); + await page.route("**/api/private-zoom/albums**", async (route) => { + await route.fulfill({ + json: apiEnvelope({ items: [], creditBalance: 80 }), + }); + }); + await page.context().route("**/api/**", async (route) => { + const pathname = new URL(route.request().url()).pathname; + if (pathname === "/api/auth/session") { + await route.fulfill({ + json: { + expires: "2099-12-31T23:59:59.000Z", + user: { email: null, image: null, name: null }, + }, + }); + return; + } + if (pathname === "/api/characters") { + await route.fulfill({ json: apiEnvelope(chatCharactersResponse) }); + return; + } + if (pathname === "/api/user/profile") { + await route.fulfill({ json: apiEnvelope(e2eEmailUser) }); + return; + } + if (pathname === "/api/user/entitlements") { + await route.fulfill({ json: apiEnvelope(userEntitlementsResponse) }); + return; + } + if (pathname === "/api/chat/history") { + await route.fulfill({ json: apiEnvelope(emptyChatHistoryResponse) }); + return; + } + if (pathname === "/api/chat/previews") { + await route.fulfill({ json: apiEnvelope(emptyChatPreviewsResponse) }); + return; + } + if (pathname === "/api/payment/plans") { + await route.fulfill({ + json: apiEnvelope({ + ...paymentPlansResponse, + isFirstRecharge: true, + }), + }); + return; + } + if (pathname === "/api/payment/vip-status") { + await route.fulfill({ + json: apiEnvelope({ isVip: false, expiresAt: null }), + }); + return; + } + if (pathname === "/api/private-zoom/albums") { + await route.fulfill({ + json: apiEnvelope({ items: [], creditBalance: 80 }), + }); + return; + } + await route.fallback(); + }); +} + +function collectBrowserErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + return errors; +} + +function isFeatureRuntimeError(message: string): boolean { + const isKnownLocalPreviewDependencyError = + message.includes("[ApiLoggingInterceptor]") || + message.includes("[next-auth][error][CLIENT_FETCH_ERROR]") || + message.includes("[Result] Result.wrap caught exception"); + return !isKnownLocalPreviewDependencyError; +} + +async function savePreview(page: Page, fileName: string): Promise { + if (process.env.FRONTEND_PREVIEW_SCREENSHOTS !== "1") return; + await page.screenshot({ + path: path.join(previewDirectory, fileName), + animations: "disabled", + }); +} diff --git a/src/app/_components/core/__tests__/tailwind-components.test.tsx b/src/app/_components/core/__tests__/tailwind-components.test.tsx index 56b6f509..33478573 100644 --- a/src/app/_components/core/__tests__/tailwind-components.test.tsx +++ b/src/app/_components/core/__tests__/tailwind-components.test.tsx @@ -82,6 +82,7 @@ describe("core Tailwind components", () => { privateZoneLabel="Maya Private Zone" onChatClick={() => undefined} onPrivateZoneClick={() => undefined} + onMenuClick={() => undefined} />, ); const privateZoneHtml = renderToStaticMarkup( @@ -91,6 +92,7 @@ describe("core Tailwind components", () => { privateZoneLabel="Maya Private Zone" onChatClick={() => undefined} onPrivateZoneClick={() => undefined} + onMenuClick={() => undefined} />, ); @@ -107,5 +109,7 @@ describe("core Tailwind components", () => { expect(privateZoneHtml).toContain( 'data-analytics-key="navigation.private_zone"', ); + expect(chatHtml).toContain('data-analytics-key="navigation.menu"'); + expect(chatHtml).toContain("Menu"); }); }); diff --git a/src/app/_components/core/app-bottom-nav.module.css b/src/app/_components/core/app-bottom-nav.module.css index 6b3cc2e0..a4fb6b30 100644 --- a/src/app/_components/core/app-bottom-nav.module.css +++ b/src/app/_components/core/app-bottom-nav.module.css @@ -4,7 +4,7 @@ left: 50%; z-index: 30; display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); box-sizing: border-box; width: min(100vw, var(--app-max-width, 540px)); min-height: calc( diff --git a/src/app/_components/core/app-bottom-nav.tsx b/src/app/_components/core/app-bottom-nav.tsx index 0549ab54..973b0662 100644 --- a/src/app/_components/core/app-bottom-nav.tsx +++ b/src/app/_components/core/app-bottom-nav.tsx @@ -1,10 +1,10 @@ "use client"; -import { Camera, MessageCircle } from "lucide-react"; +import { Camera, Menu as MenuIcon, MessageCircle } from "lucide-react"; import styles from "./app-bottom-nav.module.css"; -export type AppBottomNavItem = "chat" | "privateZone"; +export type AppBottomNavItem = "chat" | "privateZone" | "menu"; export type AppBottomNavVariant = "warm" | "dark"; export interface AppBottomNavProps { @@ -13,6 +13,7 @@ export interface AppBottomNavProps { privateZoneLabel: string; onChatClick: () => void; onPrivateZoneClick: () => void; + onMenuClick: () => void; } export function AppBottomNav({ @@ -21,6 +22,7 @@ export function AppBottomNav({ privateZoneLabel, onChatClick, onPrivateZoneClick, + onMenuClick, }: AppBottomNavProps) { return ( ); } diff --git a/src/app/_components/core/favorite-entry-button.module.css b/src/app/_components/core/favorite-entry-button.module.css new file mode 100644 index 00000000..6aeb012f --- /dev/null +++ b/src/app/_components/core/favorite-entry-button.module.css @@ -0,0 +1,96 @@ +.wrap { + position: relative; + display: inline-flex; + flex: 0 0 auto; + justify-content: flex-end; +} + +.button { + display: inline-flex; + min-width: var(--responsive-icon-button-size, 42px); + height: var(--responsive-icon-button-size, 42px); + align-items: center; + justify-content: center; + gap: 6px; + box-sizing: border-box; + padding: 0 11px; + border: 1px solid var(--favorite-border); + border-radius: 999px; + background: var(--favorite-background); + color: var(--favorite-color); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 850; + line-height: 1; + box-shadow: var(--favorite-shadow); + backdrop-filter: blur(16px); + transition: background 160ms ease, transform 160ms ease, + box-shadow 160ms ease; + -webkit-tap-highlight-color: transparent; +} + +.dark { + --favorite-background: rgba(18, 15, 24, 0.72); + --favorite-border: rgba(255, 255, 255, 0.22); + --favorite-color: #ffffff; + --favorite-shadow: 0 10px 26px rgba(0, 0, 0, 0.24); +} + +.light { + --favorite-background: rgba(255, 255, 255, 0.88); + --favorite-border: rgba(43, 27, 34, 0.1); + --favorite-color: #34272c; + --favorite-shadow: 0 10px 24px rgba(74, 48, 58, 0.1); +} + +.favorite { + width: var(--responsive-icon-button-size, 42px); + padding: 0; +} + +.download { + color: #ffffff; + background: linear-gradient(135deg, #ff76ab, #f84d96); + border-color: rgba(255, 255, 255, 0.42); + box-shadow: 0 10px 25px rgba(248, 77, 150, 0.3); +} + +.saved { + color: #f84d96; +} + +.button:hover { + transform: translateY(-1px); + box-shadow: 0 13px 30px rgba(0, 0, 0, 0.2); +} + +.button:active { + transform: translateY(1px) scale(0.98); +} + +.button:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 3px; +} + +.light:focus-visible { + outline-color: #f84d96; +} + +.hint { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 45; + width: max-content; + max-width: min(72vw, 250px); + padding: 8px 10px; + border-radius: 10px; + background: rgba(22, 18, 25, 0.94); + color: #ffffff; + font-size: 11px; + font-weight: 700; + line-height: 1.3; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.24); +} diff --git a/src/app/_components/core/favorite-entry-button.tsx b/src/app/_components/core/favorite-entry-button.tsx new file mode 100644 index 00000000..d8585669 --- /dev/null +++ b/src/app/_components/core/favorite-entry-button.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Download, Star } from "lucide-react"; + +import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser"; +import { + hasPersistedFavoriteEntryIntent, + resolveFavoriteEntryMode, + type FavoriteEntryMode, +} from "@/lib/navigation/favorite_entry"; +import { BrowserDetector } from "@/utils/browser-detect"; +import { PlatformDetector } from "@/utils/platform-detect"; +import { pwaUtil } from "@/utils/pwa"; + +import styles from "./favorite-entry-button.module.css"; + +export interface FavoriteEntryButtonProps { + characterSlug: string; + tone?: "dark" | "light"; +} + +export function FavoriteEntryButton({ + characterSlug, + tone = "dark", +}: FavoriteEntryButtonProps) { + const [mode, setMode] = useState("favorite"); + const [installHint, setInstallHint] = useState(false); + + useEffect(() => { + const updateMode = () => { + setMode( + resolveFavoriteEntryMode({ + hasFavoriteIntent: hasPersistedFavoriteEntryIntent(), + isAndroid: PlatformDetector.isAndroid(), + isIOS: PlatformDetector.isIOS(), + isInAppBrowser: BrowserDetector.isInAppBrowser(), + isInstalled: pwaUtil.isInstalled(), + }), + ); + }; + + pwaUtil.prepareInstallPrompt(); + updateMode(); + const unsubscribe = pwaUtil.subscribe(updateMode); + return unsubscribe; + }, []); + + const handleClick = async (): Promise => { + if (mode === "favorite") { + await openChatInExternalBrowser({ characterSlug }); + return; + } + + if (mode === "download") { + pwaUtil.prepareInstallPrompt(); + const result = await pwaUtil.install(); + if (result === "accepted" || pwaUtil.isInstalled()) { + setMode("saved"); + setInstallHint(false); + return; + } + if (result === "unavailable") setInstallHint(true); + } + }; + + const label = getFavoriteEntryLabel(mode); + + return ( +
+ + {installHint ? ( + + Chrome menu → Add to Home screen + + ) : null} +
+ ); +} + +function getFavoriteEntryLabel(mode: FavoriteEntryMode): string { + if (mode === "download") return "Download"; + if (mode === "saved") return "Saved"; + return "Save CozSweet"; +} diff --git a/src/app/_components/core/index.ts b/src/app/_components/core/index.ts index 67cf80af..1a699fac 100644 --- a/src/app/_components/core/index.ts +++ b/src/app/_components/core/index.ts @@ -5,6 +5,7 @@ export * from "./app-bottom-nav"; export * from "./bottom-sheet"; export * from "./checkbox"; +export * from "./favorite-entry-button"; export * from "./loading-indicator"; export * from "./mobile-shell"; export * from "./page-loading-fallback"; diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 19b82116..8a7dd985 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -38,7 +38,6 @@ import { } from "./chat-image-overlay-url"; import { deriveIsGuest, - shouldStartExternalBrowserPrompt, } from "./chat-screen.helpers"; import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner"; import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator"; @@ -118,12 +117,6 @@ export function ChatScreen() { }); const shouldShowPwaInstall = state.historyLoaded && state.historyMessages.length >= 10; - const shouldShowBrowserHint = shouldStartExternalBrowserPrompt({ - hasInitialized: authState.hasInitialized, - isLoading: authState.isLoading, - loginStatus: authState.loginStatus, - }); - useChatGuestLogin({ dispatch: authDispatch, hasInitialized: authState.hasInitialized, @@ -276,13 +269,13 @@ export function ChatScreen() {
} /> diff --git a/src/app/chat/components/__tests__/tailwind-components.test.tsx b/src/app/chat/components/__tests__/tailwind-components.test.tsx index 03d84a9e..e44cb348 100644 --- a/src/app/chat/components/__tests__/tailwind-components.test.tsx +++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx @@ -242,12 +242,6 @@ describe("chat Tailwind components", () => { const memberHtml = renderWithCharacter( Offer
} />, ); - const memberWithHintHtml = renderWithCharacter( - , - ); - const guestWithHintHtml = renderWithCharacter( - , - ); expect(guestHtml).toContain('aria-label="Sign up to unlock more features"'); expect(guestHtml).toContain("bg-accent"); @@ -257,50 +251,33 @@ describe("chat Tailwind components", () => { expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"'); expect(guestHtml).toContain("px-(--chat-inline-padding,16px)"); expect(guestHtml).not.toContain('aria-label="Profile"'); + expect(guestHtml).toContain('aria-label="Save CozSweet"'); expect(memberHtml).toContain("Offer"); expect(memberHtml).toContain('href="/characters/elio/splash"'); expect(memberHtml).toContain('aria-label="Back to home"'); - expect(memberHtml).toContain('aria-label="Profile"'); - expect(memberHtml).toContain('data-analytics-key="chat.open_profile"'); + expect(memberHtml).toContain('aria-label="Save CozSweet"'); + expect(memberHtml).toContain('data-analytics-key="favorite.favorite"'); expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"'); expect(memberHtml).toContain("px-(--chat-inline-padding,16px)"); expect(memberHtml).not.toContain("px-(--spacing-md,12px)"); - expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]"); - expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)"); - expect(memberHtml).toContain("lucide-user-round"); - expect(memberWithHintHtml).toContain( - 'data-analytics-key="chat.external_browser_hint"', - ); - expect(memberWithHintHtml).toContain( + expect(memberHtml).toContain( "grid-cols-[auto_minmax(0,1fr)_auto]", ); - expect(memberWithHintHtml.indexOf('aria-label="Back to home"')).toBeLessThan( - memberWithHintHtml.indexOf( - 'data-analytics-key="chat.external_browser_hint"', - ), - ); - expect( - memberWithHintHtml.indexOf( - 'data-analytics-key="chat.external_browser_hint"', - ), - ).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Profile"')); - expect(guestWithHintHtml).not.toContain( + expect(memberHtml).not.toContain( 'data-analytics-key="chat.external_browser_hint"', ); }); - it("renders the user avatar in the ChatHeader Profile action", () => { + it("replaces the ChatHeader Profile avatar with the favorite action", () => { userMocks.avatarUrl = "/images/avatar/profile-user.png"; const html = renderWithCharacter(); - expect(html).toContain("%2Fimages%2Favatar%2Fprofile-user.png"); - expect(html).toContain('aria-label="Profile"'); - expect(html).toContain('data-analytics-key="chat.open_profile"'); - expect(html).toContain( - "var(--responsive-icon-button-size, 42px)", - ); - expect(html).not.toContain("lucide-user-round"); + expect(html).not.toContain("%2Fimages%2Favatar%2Fprofile-user.png"); + expect(html).not.toContain('aria-label="Profile"'); + expect(html).not.toContain('data-analytics-key="chat.open_profile"'); + expect(html).toContain('aria-label="Save CozSweet"'); + expect(html).toContain("lucide-star"); }); it("links guest chat back to the active character splash", () => { @@ -315,6 +292,7 @@ describe("chat Tailwind components", () => { expect(html).toContain('href="/characters/maya/splash"'); expect(html).not.toContain('aria-label="Profile"'); + expect(html).toContain('aria-label="Save CozSweet"'); expect(html).not.toContain( 'data-analytics-key="chat.external_browser_hint"', ); diff --git a/src/app/chat/components/chat-header.tsx b/src/app/chat/components/chat-header.tsx index 97566b34..f2a9350e 100644 --- a/src/app/chat/components/chat-header.tsx +++ b/src/app/chat/components/chat-header.tsx @@ -2,37 +2,31 @@ /** * ChatHeader 顶部栏 * - * Profile 优先展示用户头像;头像缺失时回退到 lucide-react 。 + * 顶部保持返回、首充优惠、收藏入口三段结构。 */ import type { ReactNode } from "react"; -import { Lock, UserRound } from "lucide-react"; +import { Lock } from "lucide-react"; -import { BackButton, UserMessageAvatar } from "@/app/_components"; -import { useActiveCharacterRoutes } from "@/providers/character-provider"; -import { buildGlobalPageUrl } from "@/router/global-route-context"; -import { ROUTES } from "@/router/routes"; +import { BackButton } from "@/app/_components"; +import { FavoriteEntryButton } from "@/app/_components/core"; +import { + useActiveCharacter, + useActiveCharacterRoutes, +} from "@/providers/character-provider"; import { useAppNavigator } from "@/router/use-app-navigator"; -import { useUserSelector } from "@/stores/user/user-context"; - -import { BrowserHintOverlay } from "./browser-hint-overlay"; export interface ChatHeaderProps { isGuest: boolean; offerBanner?: ReactNode; - showBrowserHint?: boolean; } export function ChatHeader({ isGuest, offerBanner, - showBrowserHint = false, }: ChatHeaderProps) { const navigator = useAppNavigator(); + const character = useActiveCharacter(); const characterRoutes = useActiveCharacterRoutes(); - const avatarUrl = useUserSelector((state) => state.context.avatarUrl); - const handleOpenProfile = () => { - navigator.push(buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat)); - }; return (
@@ -52,17 +46,9 @@ export function ChatHeader({ /> Sign up to unlock more features - ) : ( - offerBanner - )} + ) : null} -
+
- {!isGuest ? ( - <> -
- {showBrowserHint ? : null} -
+
{offerBanner}
- {avatarUrl ? ( - - ) : ( - - )} - - ) : null} +
); diff --git a/src/app/chat/components/first-recharge-offer-banner.module.css b/src/app/chat/components/first-recharge-offer-banner.module.css index cbfa29e7..20fc2f46 100644 --- a/src/app/chat/components/first-recharge-offer-banner.module.css +++ b/src/app/chat/components/first-recharge-offer-banner.module.css @@ -121,8 +121,73 @@ cursor: pointer; } +.compactButton { + display: grid; + min-width: 0; + max-width: 224px; + min-height: 44px; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 5px 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 16px; + background: + radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.68), transparent 38%), + linear-gradient(135deg, #fff0b8 0%, #ffd0df 50%, #ff6cab 100%); + color: #2c111d; + cursor: pointer; + font: inherit; + text-align: left; + box-shadow: 0 9px 24px rgba(114, 21, 63, 0.2); + animation: firstRechargeBannerIn 260ms ease-out both; +} + +.compactIcon { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border-radius: 10px; + background: rgba(255, 255, 255, 0.55); + color: #f90073; +} + +.compactCopy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + font-size: 10px; + font-weight: 800; + line-height: 1.05; + text-transform: uppercase; +} + +.compactCopy span, +.compactCopy strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compactCopy span { + color: rgba(44, 17, 29, 0.68); + letter-spacing: 0.03em; +} + +.compactCopy strong { + color: #ec006d; + font-size: 15px; + font-weight: 950; + letter-spacing: -0.02em; +} + .contentButton:focus-visible, -.closeButton:focus-visible { +.closeButton:focus-visible, +.compactButton:focus-visible { outline: 2px solid rgba(255, 255, 255, 0.94); outline-offset: -3px; } @@ -144,4 +209,14 @@ flex-direction: column; gap: 2px; } + + .compactButton { + gap: 5px; + padding-inline: 6px; + } + + .compactIcon { + width: 26px; + height: 26px; + } } diff --git a/src/app/chat/components/first-recharge-offer-banner.tsx b/src/app/chat/components/first-recharge-offer-banner.tsx index dfb3f7bc..298de0ab 100644 --- a/src/app/chat/components/first-recharge-offer-banner.tsx +++ b/src/app/chat/components/first-recharge-offer-banner.tsx @@ -9,6 +9,7 @@ export interface FirstRechargeOfferBannerProps { discountPercent: number; onClick: () => void; onClose: () => void; + variant?: "banner" | "compact"; } export function FirstRechargeOfferBanner({ @@ -16,9 +17,32 @@ export function FirstRechargeOfferBanner({ discountPercent, onClick, onClose, + variant = "banner", }: FirstRechargeOfferBannerProps) { if (!visible) return null; + if (variant === "compact") { + return ( + + ); + } + return (
) : null} + + + navigator.push(navigation.splashUrl, { scroll: false }) + } + onPrivateZoomClick={() => + navigator.push(characterRoutes.privateZoom, { scroll: false }) + } + onMenuClick={() => undefined} + /> ); diff --git a/src/app/splash/components/splash-screen.module.css b/src/app/splash/components/splash-screen.module.css index b1db338e..0b3aad53 100644 --- a/src/app/splash/components/splash-screen.module.css +++ b/src/app/splash/components/splash-screen.module.css @@ -22,6 +22,13 @@ pointer-events: none; } +.favoriteAction { + position: absolute; + top: calc(var(--page-padding-y, 20px) + var(--app-safe-top, 0px)); + right: calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px)); + z-index: 4; +} + .content { position: relative; z-index: 2; diff --git a/src/app/splash/splash-screen.tsx b/src/app/splash/splash-screen.tsx index 0702d1f7..f1c69bad 100644 --- a/src/app/splash/splash-screen.tsx +++ b/src/app/splash/splash-screen.tsx @@ -2,7 +2,13 @@ import { useEffect } from "react"; -import { AppBottomNav, MobileShell } from "@/app/_components/core"; +import { + AppBottomNav, + FavoriteEntryButton, + MobileShell, +} from "@/app/_components/core"; +import { buildGlobalPageUrl } from "@/router/global-route-context"; +import { ROUTES } from "@/router/routes"; import { useAppNavigator } from "@/router/use-app-navigator"; import { useActiveCharacter, @@ -45,6 +51,13 @@ export function SplashScreen() { navigator.push(characterRoutes.splash, { scroll: false }); }; + const handleOpenMenu = () => { + navigator.push( + buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat), + { scroll: false }, + ); + }; + useEffect(() => { pwaUtil.prepareInstallPrompt(); }, []); @@ -55,6 +68,12 @@ export function SplashScreen() { {/* 渐变叠加层:accent → transparent (bottom-left → center-right) */}