From d1ccf3631efbdd3ba9f26390b31cb1451a0b345f Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 1 Jul 2026 13:01:35 +0800 Subject: [PATCH] feat(chat): prompt before credit top up --- src/app/chat/chat-screen.tsx | 31 ++++-- src/app/chat/components/index.ts | 1 + .../insufficient-credits-dialog.module.css | 102 ++++++++++++++++++ .../insufficient-credits-dialog.tsx | 77 +++++++++++++ .../[messageId]/chat-image-viewer-screen.tsx | 61 ++++++----- 5 files changed, 237 insertions(+), 35 deletions(-) create mode 100644 src/app/chat/components/insufficient-credits-dialog.module.css create mode 100644 src/app/chat/components/insufficient-credits-dialog.tsx diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 8d174856..348bf34c 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -30,6 +30,7 @@ import { ChatQuotaExhaustedBanner, ExternalBrowserDialog, HistoryUnlockDialog, + InsufficientCreditsDialog, PwaInstallOverlay, } from "./components"; import { @@ -49,6 +50,7 @@ export function ChatScreen() { const router = useRouter(); const [showExternalBrowserDialog, setShowExternalBrowserDialog] = useState(false); + const unlockPaywallRequest = state.unlockPaywallRequest; // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) const isGuest = deriveIsGuest(authState.loginStatus); @@ -132,13 +134,16 @@ export function ChatScreen() { state.historyLoaded, ]); - useEffect(() => { - const request = state.unlockPaywallRequest; - if (!request) return; + function handleCloseInsufficientCreditsDialog(): void { + chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); + } + + function handleConfirmInsufficientCreditsDialog(): void { + if (!unlockPaywallRequest) return; savePendingChatUnlock({ - messageId: request.messageId, - kind: request.kind, + messageId: unlockPaywallRequest.messageId, + kind: unlockPaywallRequest.kind, returnUrl: ROUTES.chat, stage: "payment", }); @@ -149,12 +154,7 @@ export function ChatScreen() { { returnTo: "chat" }, ), ); - }, [ - chatDispatch, - router, - state.unlockPaywallRequest, - userState.isVip, - ]); + } async function handleOpenExternalBrowser(): Promise { setShowExternalBrowserDialog(false); @@ -263,6 +263,15 @@ export function ChatScreen() { onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })} onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} /> + + ); diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index 2069e9b3..b6870668 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -16,6 +16,7 @@ export * from "./external-browser-dialog"; export * from "./fullscreen-image-viewer"; export * from "./history-unlock-dialog"; export * from "./image-bubble"; +export * from "./insufficient-credits-dialog"; export * from "./language-dialog"; export * from "./lottie-message-bubble"; export * from "./message-avatar"; diff --git a/src/app/chat/components/insufficient-credits-dialog.module.css b/src/app/chat/components/insufficient-credits-dialog.module.css new file mode 100644 index 00000000..8ae00a0f --- /dev/null +++ b/src/app/chat/components/insufficient-credits-dialog.module.css @@ -0,0 +1,102 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 220; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.52); +} + +.dialog { + width: 100%; + max-width: var(--pwa-install-dialog-max-width, 360px); + padding: 24px 18px 18px; + text-align: center; + background: var(--color-page-background, #ffffff); + border: 1px solid rgba(246, 87, 160, 0.16); + border-radius: 36px; + box-shadow: 0 18px 46px rgba(38, 18, 31, 0.18); +} + +.title { + margin: 0 0 10px; + color: var(--color-text-foreground, #171717); + font-size: var(--font-size-22, 22px); + font-weight: 700; + line-height: 1.2; +} + +.content { + margin: 0 12px 18px; + color: #393939; + font-size: var(--font-size-lg, 16px); + line-height: 1.5; +} + +.creditList { + display: grid; + gap: 8px; + margin: 0 0 18px; + padding: 12px; + background: #fff4f9; + border-radius: 22px; +} + +.creditItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #4a3b43; + font-size: var(--font-size-md, 14px); + line-height: 1.3; +} + +.creditItem dt { + margin: 0; + font-weight: 500; +} + +.creditItem dd { + margin: 0; + color: #f657a0; + font-size: var(--font-size-lg, 16px); + font-weight: 800; +} + +.actions { + display: flex; + gap: var(--spacing-md, 12px); + width: 100%; +} + +.button { + flex: 1 1 auto; + height: var(--pwa-button-height, 44px); + border: 0; + border-radius: var(--radius-bottom-sheet, 28px); + font-size: var(--font-size-lg, 16px); + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.secondary { + color: var(--color-page-background, #ffffff); + background: var(--color-text-secondary, #9e9e9e); +} + +.primary { + color: var(--color-page-background, #ffffff); + background: linear-gradient(90deg, #ff67e0 0%, #ff52a2 100%); + box-shadow: 0 6px 14px rgba(246, 87, 160, 0.28); +} + +.button:focus-visible { + outline: 2px solid #f657a0; + outline-offset: 3px; +} diff --git a/src/app/chat/components/insufficient-credits-dialog.tsx b/src/app/chat/components/insufficient-credits-dialog.tsx new file mode 100644 index 00000000..a0256f98 --- /dev/null +++ b/src/app/chat/components/insufficient-credits-dialog.tsx @@ -0,0 +1,77 @@ +"use client"; + +import styles from "./insufficient-credits-dialog.module.css"; + +export interface InsufficientCreditsDialogProps { + open: boolean; + creditBalance: number; + requiredCredits: number; + shortfallCredits: number; + onClose: () => void; + onConfirm: () => void; +} + +export function InsufficientCreditsDialog({ + open, + creditBalance, + requiredCredits, + shortfallCredits, + onClose, + onConfirm, +}: InsufficientCreditsDialogProps) { + if (!open) return null; + + const showCreditDetail = requiredCredits > 0 || shortfallCredits > 0; + + return ( +
+
+

+ Not enough credits +

+

+ Top up your credits to unlock this message and keep the moment going. +

+ + {showCreditDetail ? ( +
+
+
Balance
+
{creditBalance}
+
+
+
Required
+
{requiredCredits}
+
+
+
Still needed
+
{shortfallCredits}
+
+
+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx b/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx index f46f4382..cb08007f 100644 --- a/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx +++ b/src/app/chat/image/[messageId]/chat-image-viewer-screen.tsx @@ -15,7 +15,11 @@ import { } from "@/lib/navigation/chat_unlock_session"; import { getInsufficientCreditsSubscriptionType } from "../../chat-screen.helpers"; -import { FullscreenImageViewer, HistoryUnlockDialog } from "../../components"; +import { + FullscreenImageViewer, + HistoryUnlockDialog, + InsufficientCreditsDialog, +} from "../../components"; import styles from "./chat-image-viewer-screen.module.css"; export interface ChatImageViewerScreenProps { @@ -31,6 +35,11 @@ export function ChatImageViewerScreen({ const chatState = useChatState(); const chatDispatch = useChatDispatch(); const returnUrl = ROUTE_BUILDERS.chatImage(messageId); + const unlockPaywallRequest = + chatState.unlockPaywallRequest?.kind === "image" && + chatState.unlockPaywallRequest.messageId === messageId + ? chatState.unlockPaywallRequest + : null; const isAuthenticatedUser = authState.loginStatus !== "notLoggedIn" && authState.loginStatus !== "guest"; @@ -67,15 +76,16 @@ export function ChatImageViewerScreen({ returnUrl, ]); - useEffect(() => { - const request = chatState.unlockPaywallRequest; - if (!request || request.kind !== "image" || request.messageId !== messageId) { - return; - } + const handleCloseInsufficientCreditsDialog = () => { + chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); + }; + + const handleConfirmInsufficientCreditsDialog = () => { + if (!unlockPaywallRequest) return; savePendingChatUnlock({ - messageId: request.messageId, - kind: request.kind, + messageId: unlockPaywallRequest.messageId, + kind: unlockPaywallRequest.kind, returnUrl, stage: "payment", }); @@ -86,14 +96,7 @@ export function ChatImageViewerScreen({ { returnTo: "chat" }, ), ); - }, [ - chatDispatch, - chatState.unlockPaywallRequest, - messageId, - returnUrl, - router, - userState.isVip, - ]); + }; const handleClose = () => { router.push(ROUTES.chat); @@ -119,14 +122,24 @@ export function ChatImageViewerScreen({ }; const historyUnlockDialog = ( - chatDispatch({ type: "ChatUnlockHistoryDismissed" })} - onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} - /> + <> + chatDispatch({ type: "ChatUnlockHistoryDismissed" })} + onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} + /> + + ); if (!message) {