diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index cd37a89e..9b12b18a 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -24,7 +24,7 @@ import { ChatInputBar, ChatInsufficientCreditsBanner, ExternalBrowserDialog, - FirstRechargeOfferDialog, + FirstRechargeOfferBanner, HistoryUnlockDialog, InsufficientCreditsDialog, PwaInstallOverlay, @@ -36,7 +36,7 @@ import { isChatDevelopmentEnvironment, shouldStartExternalBrowserPrompt, } from "./chat-screen.helpers"; -import { useFirstRechargeOfferDialog } from "./hooks/use-first-recharge-offer-dialog"; +import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner"; import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow"; import styles from "./components/chat-screen.module.css"; @@ -65,15 +65,9 @@ export function ChatScreen() { const messageLimitTitle = "Insufficient credits\nTop up to continue chatting"; const messageLimitCtaLabel = "Top up credits to continue"; - const hasBlockingDialog = - showExternalBrowserDialog || - showMessageLimitBanner || - state.unlockHistoryPromptVisible || - unlockPaywallRequest !== null; - const firstRechargeOfferDialog = useFirstRechargeOfferDialog({ + const firstRechargeOfferBanner = useFirstRechargeOfferBanner({ historyLoaded: state.historyLoaded, loginStatus: authState.loginStatus, - hasBlockingDialog, }); const externalBrowserPromptShownRef = useRef(false); @@ -159,7 +153,17 @@ export function ChatScreen() {
{/* isGuest 派生自 auth.loginStatus */} - + + } + /> - -
); diff --git a/src/app/chat/components/chat-header.module.css b/src/app/chat/components/chat-header.module.css index 971080f6..bb7708b9 100644 --- a/src/app/chat/components/chat-header.module.css +++ b/src/app/chat/components/chat-header.module.css @@ -4,7 +4,8 @@ .header { flex: 0 0 auto; display: flex; - align-items: center; + flex-direction: column; + align-items: stretch; padding: 0; background: transparent; } @@ -33,7 +34,6 @@ /* 登录模式行容器(Dart: Container(margin-left 12, padding: vertical 8 / horizontal 12)) */ .headerRow { - width: 100%; display: flex; align-items: center; justify-content: space-between; diff --git a/src/app/chat/components/chat-header.tsx b/src/app/chat/components/chat-header.tsx index d60c7ac7..8a63cb28 100644 --- a/src/app/chat/components/chat-header.tsx +++ b/src/app/chat/components/chat-header.tsx @@ -5,6 +5,7 @@ * 图标:lucide-react (游客 banner)+ (菜单按钮) * tree-shakable,currentColor 继承父 color */ +import type { ReactNode } from "react"; import { Lock, Menu } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -14,9 +15,10 @@ import styles from "./chat-header.module.css"; export interface ChatHeaderProps { isGuest: boolean; + offerBanner?: ReactNode; } -export function ChatHeader({ isGuest }: ChatHeaderProps) { +export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) { const router = useRouter(); if (isGuest) { @@ -37,6 +39,8 @@ export function ChatHeader({ isGuest }: ChatHeaderProps) { return (
+ {offerBanner} +
{/* 菜单按钮(点击 → push 到 /sidebar,与 Dart MenuButton 一致) */} + + + + ); +} diff --git a/src/app/chat/components/first-recharge-offer-dialog.module.css b/src/app/chat/components/first-recharge-offer-dialog.module.css deleted file mode 100644 index bcdadec1..00000000 --- a/src/app/chat/components/first-recharge-offer-dialog.module.css +++ /dev/null @@ -1,116 +0,0 @@ -.overlay { - position: fixed; - inset: 0; - z-index: 215; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; - background: - radial-gradient(circle at 50% 28%, rgba(255, 103, 224, 0.26), transparent 34%), - rgba(0, 0, 0, 0.52); -} - -.dialog { - position: relative; - overflow: hidden; - width: 100%; - max-width: var(--pwa-install-dialog-max-width, 360px); - padding: 24px 18px 18px; - border: 1px solid rgba(255, 103, 224, 0.3); - border-radius: 34px; - background: - linear-gradient(180deg, rgba(255, 250, 253, 0.98) 0%, rgba(255, 238, 247, 0.98) 100%), - #ffffff; - box-shadow: 0 20px 52px rgba(39, 14, 30, 0.26); - text-align: center; -} - -.dialog::before { - position: absolute; - top: -58px; - right: -42px; - width: 138px; - height: 138px; - border-radius: 999px; - background: rgba(255, 138, 52, 0.18); - content: ""; -} - -.badge { - position: relative; - display: inline-flex; - padding: 6px 10px; - border-radius: 999px; - background: rgba(255, 95, 174, 0.12); - color: #f657a0; - font-size: 12px; - font-weight: 900; - letter-spacing: 0.02em; - line-height: 1; - text-transform: uppercase; -} - -.discount { - position: relative; - margin-top: 12px; - color: #181014; - font-size: 46px; - font-weight: 900; - letter-spacing: -1.5px; - line-height: 1; -} - -.title { - position: relative; - margin: 10px 0 0; - color: #181014; - font-size: 22px; - font-weight: 900; - line-height: 1.18; -} - -.content { - position: relative; - margin: 10px 12px 20px; - color: #5f4d56; - font-size: 15px; - font-weight: 600; - line-height: 1.45; -} - -.actions { - position: relative; - display: flex; - gap: var(--spacing-md, 12px); - width: 100%; -} - -.button { - display: flex; - flex: 1 1 auto; - height: var(--pwa-button-height, 44px); - align-items: center; - justify-content: center; - border: 0; - border-radius: var(--radius-bottom-sheet, 28px); - cursor: pointer; - font-size: var(--font-size-lg, 16px); - font-weight: 800; -} - -.secondary { - background: rgba(24, 16, 20, 0.1); - color: #5f4d56; -} - -.primary { - background: linear-gradient(90deg, #ff67e0 0%, #ff52a2 100%); - color: #ffffff; - box-shadow: 0 8px 18px rgba(246, 87, 160, 0.28); -} - -.button:focus-visible { - outline: 2px solid #f657a0; - outline-offset: 3px; -} diff --git a/src/app/chat/components/first-recharge-offer-dialog.tsx b/src/app/chat/components/first-recharge-offer-dialog.tsx deleted file mode 100644 index 8fd74496..00000000 --- a/src/app/chat/components/first-recharge-offer-dialog.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import styles from "./first-recharge-offer-dialog.module.css"; - -export interface FirstRechargeOfferDialogProps { - open: boolean; - discountPercent: number; - onClose: () => void; - onClaim: () => void; -} - -export function FirstRechargeOfferDialog({ - open, - discountPercent, - onClose, - onClaim, -}: FirstRechargeOfferDialogProps) { - if (!open) return null; - - return ( -
-
- Limited offer -
{discountPercent}% OFF
-

- First Recharge Offer -

-

- Your first recharge discount is ready. Claim it now and keep the - conversation flowing. -

-
- - -
-
-
- ); -} diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index f89c2253..b5ba34ff 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -12,7 +12,7 @@ export * from "./chat-input-text-field"; export * from "./chat-send-button"; export * from "./date-header"; export * from "./external-browser-dialog"; -export * from "./first-recharge-offer-dialog"; +export * from "./first-recharge-offer-banner"; export * from "./fullscreen-image-viewer"; export * from "./history-unlock-dialog"; export * from "./image-bubble"; diff --git a/src/app/chat/hooks/__tests__/use-first-recharge-offer-dialog.test.ts b/src/app/chat/hooks/__tests__/use-first-recharge-offer-banner.test.ts similarity index 52% rename from src/app/chat/hooks/__tests__/use-first-recharge-offer-dialog.test.ts rename to src/app/chat/hooks/__tests__/use-first-recharge-offer-banner.test.ts index 9bbb62d2..f6df1d15 100644 --- a/src/app/chat/hooks/__tests__/use-first-recharge-offer-dialog.test.ts +++ b/src/app/chat/hooks/__tests__/use-first-recharge-offer-banner.test.ts @@ -1,48 +1,59 @@ import { describe, expect, it } from "vitest"; -import { shouldShowFirstRechargeOfferDialog } from "../use-first-recharge-offer-dialog"; +import { shouldShowFirstRechargeOfferBanner } from "../use-first-recharge-offer-banner"; -describe("shouldShowFirstRechargeOfferDialog", () => { +describe("shouldShowFirstRechargeOfferBanner", () => { it("shows for authenticated users when first recharge offer is available", () => { expect( - shouldShowFirstRechargeOfferDialog({ + shouldShowFirstRechargeOfferBanner({ historyLoaded: true, loginStatus: "facebook", isFirstRecharge: true, - hasBlockingDialog: false, + dismissed: false, }), ).toBe(true); }); it("does not show for guest users", () => { expect( - shouldShowFirstRechargeOfferDialog({ + shouldShowFirstRechargeOfferBanner({ historyLoaded: true, loginStatus: "guest", isFirstRecharge: true, - hasBlockingDialog: false, + dismissed: false, }), ).toBe(false); }); it("does not show unless isFirstRecharge is true", () => { expect( - shouldShowFirstRechargeOfferDialog({ + shouldShowFirstRechargeOfferBanner({ historyLoaded: true, loginStatus: "google", isFirstRecharge: false, - hasBlockingDialog: false, + dismissed: false, }), ).toBe(false); }); - it("does not show over a higher priority dialog", () => { + it("does not show after the user dismisses it", () => { expect( - shouldShowFirstRechargeOfferDialog({ + shouldShowFirstRechargeOfferBanner({ historyLoaded: true, loginStatus: "apple", isFirstRecharge: true, - hasBlockingDialog: true, + dismissed: true, + }), + ).toBe(false); + }); + + it("waits until chat history has loaded", () => { + expect( + shouldShowFirstRechargeOfferBanner({ + historyLoaded: false, + loginStatus: "email", + isFirstRecharge: true, + dismissed: false, }), ).toBe(false); }); diff --git a/src/app/chat/hooks/use-first-recharge-offer-banner.ts b/src/app/chat/hooks/use-first-recharge-offer-banner.ts new file mode 100644 index 00000000..88fb5b62 --- /dev/null +++ b/src/app/chat/hooks/use-first-recharge-offer-banner.ts @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +import type { LoginStatus } from "@/data/dto/auth"; +import { ROUTE_BUILDERS } from "@/router/routes"; +import { usePaymentDispatch, usePaymentState } from "@/stores/payment"; +import { useUserState } from "@/stores/user/user-context"; +import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; +import { + getFirstRechargeOfferBannerDismissed, + recordFirstRechargeOfferBannerDismissed, +} from "@/lib/chat/first_recharge_offer_banner"; + +export interface FirstRechargeOfferBannerEligibility { + historyLoaded: boolean; + loginStatus: LoginStatus; + isFirstRecharge: boolean; + dismissed: boolean; +} + +export interface UseFirstRechargeOfferBannerInput { + historyLoaded: boolean; + loginStatus: LoginStatus; +} + +export interface UseFirstRechargeOfferBannerOutput { + visible: boolean; + discountPercent: number; + close: () => void; + claim: () => void; +} + +interface DismissalState { + userId: string | null; + loaded: boolean; + dismissed: boolean; +} + +export function shouldShowFirstRechargeOfferBanner( + input: FirstRechargeOfferBannerEligibility, +): boolean { + if (!input.historyLoaded) return false; + if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") { + return false; + } + if (!input.isFirstRecharge) return false; + return !input.dismissed; +} + +export function useFirstRechargeOfferBanner({ + historyLoaded, + loginStatus, +}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput { + const router = useRouter(); + const payment = usePaymentState(); + const paymentDispatch = usePaymentDispatch(); + const userState = useUserState(); + const isAuthenticatedUser = + loginStatus !== "notLoggedIn" && loginStatus !== "guest"; + const userId = userState.currentUser?.id ?? null; + const hasUserIdentity = !isAuthenticatedUser || Boolean(userId); + const [dismissalState, setDismissalState] = useState(() => ({ + userId, + loaded: false, + dismissed: false, + })); + const discountPercent = payment.firstRechargeOffer?.discountPercent ?? 50; + + useEffect(() => { + if (!isAuthenticatedUser) return; + if (payment.status !== "idle") return; + const defaultPayChannel = getDefaultPayChannelForCountryCode( + userState.currentUser?.countryCode, + ); + paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel }); + }, [ + payment.status, + paymentDispatch, + isAuthenticatedUser, + userState.currentUser?.countryCode, + ]); + + useEffect(() => { + let cancelled = false; + + const loadDismissal = async () => { + const dismissed = await getFirstRechargeOfferBannerDismissed(userId); + if (cancelled) return; + + setDismissalState({ + userId, + loaded: true, + dismissed, + }); + }; + + void loadDismissal(); + + return () => { + cancelled = true; + }; + }, [userId]); + + const dismissalLoaded = + dismissalState.userId === userId && dismissalState.loaded; + const visible = + hasUserIdentity && + dismissalLoaded && + shouldShowFirstRechargeOfferBanner({ + historyLoaded, + loginStatus, + isFirstRecharge: payment.isFirstRecharge, + dismissed: dismissalState.dismissed, + }); + + function close(): void { + setDismissalState({ + userId, + loaded: true, + dismissed: true, + }); + void recordFirstRechargeOfferBannerDismissed(userId); + } + + function claim(): void { + const defaultPayChannel = getDefaultPayChannelForCountryCode( + userState.currentUser?.countryCode, + ); + router.push( + ROUTE_BUILDERS.subscription("vip", { + payChannel: defaultPayChannel, + returnTo: "chat", + }), + ); + } + + return { + visible, + discountPercent, + close, + claim, + }; +} diff --git a/src/app/chat/hooks/use-first-recharge-offer-dialog.ts b/src/app/chat/hooks/use-first-recharge-offer-dialog.ts deleted file mode 100644 index 484f3383..00000000 --- a/src/app/chat/hooks/use-first-recharge-offer-dialog.ts +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; - -import type { LoginStatus } from "@/data/dto/auth"; -import { ROUTE_BUILDERS } from "@/router/routes"; -import { usePaymentDispatch, usePaymentState } from "@/stores/payment"; -import { useUserState } from "@/stores/user/user-context"; -import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; -import { Result, SpAsyncUtil, todayString } from "@/utils"; - -export const FIRST_RECHARGE_OFFER_DIALOG_DELAY_MS = 4000; - -const FIRST_RECHARGE_OFFER_DIALOG_KEY_PREFIX = - "cozsweet:firstRechargeOfferDialogShown"; - -export interface FirstRechargeOfferDialogEligibility { - historyLoaded: boolean; - loginStatus: LoginStatus; - isFirstRecharge: boolean; - hasBlockingDialog: boolean; -} - -export interface UseFirstRechargeOfferDialogInput { - historyLoaded: boolean; - loginStatus: LoginStatus; - hasBlockingDialog: boolean; -} - -export interface UseFirstRechargeOfferDialogOutput { - open: boolean; - discountPercent: number; - close: () => void; - claim: () => void; -} - -export function shouldShowFirstRechargeOfferDialog( - input: FirstRechargeOfferDialogEligibility, -): boolean { - if (!input.historyLoaded) return false; - if (input.loginStatus === "notLoggedIn" || input.loginStatus === "guest") { - return false; - } - if (!input.isFirstRecharge) return false; - return !input.hasBlockingDialog; -} - -export function useFirstRechargeOfferDialog({ - historyLoaded, - loginStatus, - hasBlockingDialog, -}: UseFirstRechargeOfferDialogInput): UseFirstRechargeOfferDialogOutput { - const router = useRouter(); - const payment = usePaymentState(); - const paymentDispatch = usePaymentDispatch(); - const userState = useUserState(); - const [open, setOpen] = useState(false); - const [dismissed, setDismissed] = useState(false); - const isAuthenticatedUser = - loginStatus !== "notLoggedIn" && loginStatus !== "guest"; - - const storageKey = useMemo( - () => - `${FIRST_RECHARGE_OFFER_DIALOG_KEY_PREFIX}:${ - userState.currentUser?.id || "anonymous" - }`, - [userState.currentUser?.id], - ); - const discountPercent = payment.firstRechargeOffer?.discountPercent ?? 50; - const canShow = shouldShowFirstRechargeOfferDialog({ - historyLoaded, - loginStatus, - isFirstRecharge: payment.isFirstRecharge, - hasBlockingDialog, - }); - - useEffect(() => { - if (!isAuthenticatedUser) return; - if (payment.status !== "idle") return; - const defaultPayChannel = getDefaultPayChannelForCountryCode( - userState.currentUser?.countryCode, - ); - paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel }); - }, [ - payment.status, - paymentDispatch, - isAuthenticatedUser, - userState.currentUser?.countryCode, - ]); - - useEffect(() => { - if (!canShow || dismissed || open) return; - - let cancelled = false; - let timer: number | undefined; - - const schedule = async () => { - const shownDateResult = await SpAsyncUtil.getString(storageKey); - if ( - cancelled || - (Result.isOk(shownDateResult) && shownDateResult.data === todayString()) - ) { - return; - } - - timer = window.setTimeout(() => { - if (cancelled) return; - setOpen(true); - }, FIRST_RECHARGE_OFFER_DIALOG_DELAY_MS); - }; - - void schedule(); - - return () => { - cancelled = true; - if (timer !== undefined) window.clearTimeout(timer); - }; - }, [canShow, dismissed, open, storageKey]); - - function markShown(): void { - setDismissed(true); - setOpen(false); - void SpAsyncUtil.setString(storageKey, todayString()); - } - - function claim(): void { - markShown(); - const defaultPayChannel = getDefaultPayChannelForCountryCode( - userState.currentUser?.countryCode, - ); - router.push( - ROUTE_BUILDERS.subscription("vip", { - payChannel: defaultPayChannel, - returnTo: "chat", - }), - ); - } - - return { - open: open && !hasBlockingDialog, - discountPercent, - close: markShown, - claim, - }; -} diff --git a/src/data/storage/app/app_storage.ts b/src/data/storage/app/app_storage.ts index 979c52bc..b6419861 100644 --- a/src/data/storage/app/app_storage.ts +++ b/src/data/storage/app/app_storage.ts @@ -70,6 +70,27 @@ export class AppStorage { return SpAsyncUtil.setString(StorageKeys.lastAppInfoReported, todayString); } + // ---- first recharge offer banner ---- + + static async getFirstRechargeOfferBannerDismissed( + userId: string | null | undefined, + ): Promise> { + const r = await SpAsyncUtil.getBool( + AppStorage.firstRechargeOfferBannerDismissedKey(userId), + ); + if (!r.success) return r; + return Result.ok(r.data === true); + } + + static recordFirstRechargeOfferBannerDismissed( + userId: string | null | undefined, + ): Promise> { + return SpAsyncUtil.setBool( + AppStorage.firstRechargeOfferBannerDismissedKey(userId), + true, + ); + } + // ---- internal helpers ---- /** @@ -87,4 +108,12 @@ export class AppStorage { if (!r.success) return r; return Result.ok(r.data === null || r.data !== todayString); } + + private static firstRechargeOfferBannerDismissedKey( + userId: string | null | undefined, + ): string { + return `${StorageKeys.firstRechargeOfferBannerDismissed}:${ + userId || "anonymous" + }`; + } } diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts index 959877b5..5bfceea5 100644 --- a/src/data/storage/storage_keys.ts +++ b/src/data/storage/storage_keys.ts @@ -32,6 +32,7 @@ export const StorageKeys = { lastExternalBrowserDialogShown: "last_external_browser_dialog_shown", lastPwaEventReported: "last_pwa_event_reported", lastAppInfoReported: "last_app_info_reported", + firstRechargeOfferBannerDismissed: "first_recharge_offer_banner_dismissed", // payment pendingPaymentOrder: "pending_payment_order", diff --git a/src/lib/chat/first_recharge_offer_banner.ts b/src/lib/chat/first_recharge_offer_banner.ts new file mode 100644 index 00000000..1ba678a9 --- /dev/null +++ b/src/lib/chat/first_recharge_offer_banner.ts @@ -0,0 +1,16 @@ +"use client"; + +import { AppStorage } from "@/data/storage/app/app_storage"; + +export async function getFirstRechargeOfferBannerDismissed( + userId: string | null | undefined, +): Promise { + const result = await AppStorage.getFirstRechargeOfferBannerDismissed(userId); + return result.success ? result.data : false; +} + +export function recordFirstRechargeOfferBannerDismissed( + userId: string | null | undefined, +): Promise { + return AppStorage.recordFirstRechargeOfferBannerDismissed(userId); +}