From b7779878cfe267ac5bd94b75e511fe24dda70df7 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 29 Jun 2026 10:53:52 +0800 Subject: [PATCH] feat(chat): unlock history after payment --- src/app/chat/chat-screen.tsx | 13 +- .../history-unlock-dialog.module.css | 80 +++++++++ .../chat/components/history-unlock-dialog.tsx | 63 +++++++ src/app/chat/components/index.ts | 1 + src/providers/root-providers.tsx | 3 +- .../__tests__/chat-machine.helpers.test.ts | 43 +++++ .../chat-machine.transitions.test.ts | 119 +++++++++++++ src/stores/chat/chat-context.tsx | 8 + src/stores/chat/chat-events.ts | 3 + src/stores/chat/chat-machine.actors.ts | 27 +++ src/stores/chat/chat-machine.helpers.ts | 39 +++++ src/stores/chat/chat-machine.ts | 158 ++++++++++++++++-- src/stores/chat/chat-state.ts | 10 ++ src/stores/payment/index.ts | 1 + src/stores/payment/payment-success-sync.tsx | 41 +++++ 15 files changed, 591 insertions(+), 18 deletions(-) create mode 100644 src/app/chat/components/history-unlock-dialog.module.css create mode 100644 src/app/chat/components/history-unlock-dialog.tsx create mode 100644 src/stores/payment/payment-success-sync.tsx diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index a17c36e1..876f24a1 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { useAuthState } from "@/stores/auth/auth-context"; -import { useChatState } from "@/stores/chat/chat-context"; +import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { MobileShell } from "@/app/_components/core"; @@ -16,6 +16,7 @@ import { ChatInputBar, ChatQuotaExhaustedBanner, ExternalBrowserDialog, + HistoryUnlockDialog, PwaInstallOverlay, } from "./components"; import { @@ -31,6 +32,7 @@ import styles from "./components/chat-screen.module.css"; export function ChatScreen() { const state = useChatState(); + const chatDispatch = useChatDispatch(); const authState = useAuthState(); const router = useRouter(); const [showExternalBrowserDialog, setShowExternalBrowserDialog] = @@ -159,6 +161,15 @@ export function ChatScreen() { onClose={() => setShowExternalBrowserDialog(false)} onConfirm={() => void handleOpenExternalBrowser()} /> + + chatDispatch({ type: "ChatUnlockHistoryDismissed" })} + onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} + /> ); diff --git a/src/app/chat/components/history-unlock-dialog.module.css b/src/app/chat/components/history-unlock-dialog.module.css new file mode 100644 index 00000000..67bbe8d4 --- /dev/null +++ b/src/app/chat/components/history-unlock-dialog.module.css @@ -0,0 +1,80 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 75; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); +} + +.dialog { + width: calc(100% - 40px); + max-width: var(--pwa-install-dialog-max-width, 360px); + padding: 24px 16px 16px; + text-align: center; + background: var(--color-page-background, #ffffff); + border-radius: 40px; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); +} + +.title { + margin: 0 0 10px; + font-size: var(--font-size-22, 22px); + font-weight: 700; + line-height: 1.2; + color: var(--color-text-foreground, #171717); +} + +.content { + margin: 0 24px var(--spacing-lg, 16px); + font-size: var(--font-size-lg, 16px); + line-height: 1.5; + text-align: left; + color: #393939; +} + +.error { + margin: 0 24px var(--spacing-lg, 16px); + font-size: var(--font-size-md, 14px); + line-height: 1.4; + text-align: left; + color: #c0364c; +} + +.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: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.72; +} + +.secondary { + color: var(--color-page-background, #ffffff); + background: var(--color-text-secondary, #9e9e9e); +} + +.primary { + color: var(--color-page-background, #ffffff); + background: linear-gradient( + var(--color-button-gradient-start, #ff67e0), + var(--color-button-gradient-end, #ff52a2) + ); +} diff --git a/src/app/chat/components/history-unlock-dialog.tsx b/src/app/chat/components/history-unlock-dialog.tsx new file mode 100644 index 00000000..c1aa2df7 --- /dev/null +++ b/src/app/chat/components/history-unlock-dialog.tsx @@ -0,0 +1,63 @@ +"use client"; + +import styles from "./history-unlock-dialog.module.css"; + +export interface HistoryUnlockDialogProps { + open: boolean; + lockedCount: number; + isLoading?: boolean; + errorMessage?: string | null; + onClose: () => void; + onConfirm: () => void; +} + +export function HistoryUnlockDialog({ + open, + lockedCount, + isLoading = false, + errorMessage, + onClose, + onConfirm, +}: HistoryUnlockDialogProps) { + if (!open) return null; + + return ( +
+
+

+ Unlock previous messages? +

+

+ We found {lockedCount} locked messages in your chat history. You can + unlock them now to continue the conversation with full context. +

+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index e1e067cd..d41968a6 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -14,6 +14,7 @@ export * from "./chat-send-button"; export * from "./date-header"; export * from "./external-browser-dialog"; export * from "./fullscreen-image-viewer"; +export * from "./history-unlock-dialog"; export * from "./image-bubble"; export * from "./language-dialog"; export * from "./lottie-message-bubble"; diff --git a/src/providers/root-providers.tsx b/src/providers/root-providers.tsx index 69be2c7e..f25948d5 100644 --- a/src/providers/root-providers.tsx +++ b/src/providers/root-providers.tsx @@ -21,7 +21,7 @@ import { AuthStatusChecker } from "@/stores/auth/auth-status-checker"; import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync"; import { ChatAuthSync } from "@/stores/chat/chat-auth-sync"; import { ChatProvider } from "@/stores/chat/chat-context"; -import { PaymentProvider } from "@/stores/payment"; +import { PaymentProvider, PaymentSuccessSync } from "@/stores/payment"; import { SidebarProvider } from "@/stores/sidebar/sidebar-context"; import { UserAuthSync } from "@/stores/user/user-auth-sync"; import { UserProvider } from "@/stores/user/user-context"; @@ -44,6 +44,7 @@ export function RootProviders({ children }: RootProvidersProps) { + {children}
diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts index 296f8ada..3e60818a 100644 --- a/src/stores/chat/__tests__/chat-machine.helpers.test.ts +++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts @@ -4,6 +4,7 @@ import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import type { ChatState } from "@/stores/chat/chat-state"; import { applyHttpSendOutput, + countLockedHistoryMessages, localMessagesToUi, sendResponseToUiMessage, } from "@/stores/chat/chat-machine.helpers"; @@ -43,6 +44,11 @@ function makeChatState(overrides: Partial = {}): ChatState { hasMore: true, historyOffset: 0, historyLoaded: true, + paymentUnlockPending: false, + unlockHistoryPromptVisible: false, + lockedHistoryCount: 0, + isUnlockingHistory: false, + unlockHistoryError: null, ...overrides, }; } @@ -231,3 +237,40 @@ describe("localMessagesToUi", () => { expect(message.imagePaywalled).toBeUndefined(); }); }); + +describe("countLockedHistoryMessages", () => { + it("counts only unlockable locked AI messages", () => { + expect( + countLockedHistoryMessages([ + { + content: "", + isFromAI: true, + date: "2026-06-29", + locked: true, + lockReason: "private_message", + }, + { + content: "", + isFromAI: true, + date: "2026-06-29", + locked: true, + lockReason: "voice_message", + }, + { + content: "", + isFromAI: true, + date: "2026-06-29", + locked: true, + lockReason: "daily_limit", + }, + { + content: "user text", + isFromAI: false, + date: "2026-06-29", + locked: true, + lockReason: "private_message", + }, + ]), + ).toBe(2); + }); +}); diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index 4aeb7666..f1d96d61 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -28,6 +28,15 @@ interface SendMessageHttpOutput { reply: UiMessage | null; } +interface UnlockHistoryOutput { + unlocked: boolean; + reason: string; + shortfallCredits: number; + messages: UiMessage[]; + hasMore: boolean; + newOffset: number; +} + function makeChatSendResponse(): ChatSendResponse { return ChatSendResponse.from({ reply: "", @@ -50,6 +59,7 @@ function makeChatSendResponse(): ChatSendResponse { function createTestChatMachine( options: { historyMessages?: UiMessage[]; + unlockHistoryOutput?: UnlockHistoryOutput; } = {}, ) { return chatMachine.provide({ @@ -90,6 +100,15 @@ function createTestChatMachine( }); return () => undefined; }), + unlockHistory: fromPromise(async () => ({ + unlocked: true, + reason: "ok", + shortfallCredits: 0, + messages: [], + hasMore: false, + newOffset: 0, + ...options.unlockHistoryOutput, + })), }, }); } @@ -213,6 +232,14 @@ describe("chatMachine transitions", () => { }); return () => undefined; }), + unlockHistory: fromPromise(async () => ({ + unlocked: true, + reason: "ok", + shortfallCredits: 0, + messages: [], + hasMore: false, + newOffset: 0, + })), }, }); const actor = createActor(machine).start(); @@ -235,4 +262,96 @@ describe("chatMachine transitions", () => { actor.stop(); }); + + it("prompts before unlocking multiple locked history messages after payment", async () => { + const actor = createActor( + createTestChatMachine({ + historyMessages: [ + { + content: "", + isFromAI: true, + date: "2026-06-29", + locked: true, + lockReason: "private_message", + }, + { + content: "", + isFromAI: true, + date: "2026-06-29", + locked: true, + lockReason: "voice_message", + }, + ], + }), + ).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ type: "ChatPaymentSucceeded" }); + + expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(true); + expect(actor.getSnapshot().context.lockedHistoryCount).toBe(2); + + actor.stop(); + }); + + it("unlocks history after the prompt is confirmed", async () => { + const actor = createActor( + createTestChatMachine({ + historyMessages: [ + { + content: "", + isFromAI: true, + date: "2026-06-29", + locked: true, + lockReason: "private_message", + }, + { + content: "", + isFromAI: true, + date: "2026-06-29", + locked: true, + lockReason: "voice_message", + }, + ], + unlockHistoryOutput: { + unlocked: true, + reason: "ok", + shortfallCredits: 0, + messages: [ + { + content: "unlocked", + isFromAI: true, + date: "2026-06-29", + locked: false, + lockReason: null, + }, + ], + hasMore: false, + newOffset: 1, + }, + }), + ).start(); + + actor.send({ type: "ChatUserLogin", token: "token" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + actor.send({ type: "ChatPaymentSucceeded" }); + actor.send({ type: "ChatUnlockHistoryConfirmed" }); + await waitFor(actor, (snapshot) => + snapshot.matches({ userSession: "ready" }), + ); + + expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false); + expect(actor.getSnapshot().context.messages).toMatchObject([ + { content: "unlocked", locked: false }, + ]); + + actor.stop(); + }); }); diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index c66fe1b7..759ddfc7 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -30,6 +30,10 @@ interface ChatState { historyOffset: number; /** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */ historyLoaded: boolean; + unlockHistoryPromptVisible: boolean; + lockedHistoryCount: number; + isUnlockingHistory: boolean; + unlockHistoryError: string | null; } const ChatStateCtx = createContext(null); @@ -55,6 +59,10 @@ export function ChatProvider({ children }: ChatProviderProps) { hasMore: state.context.hasMore, historyOffset: state.context.historyOffset, historyLoaded: state.context.historyLoaded, + unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, + lockedHistoryCount: state.context.lockedHistoryCount, + isUnlockingHistory: state.context.isUnlockingHistory, + unlockHistoryError: state.context.unlockHistoryError, }), [state], ); diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 0d20a311..052425e5 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -24,6 +24,9 @@ export type ChatEvent = | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } | { type: "ChatLoadMoreHistory" } + | { type: "ChatPaymentSucceeded" } + | { type: "ChatUnlockHistoryConfirmed" } + | { type: "ChatUnlockHistoryDismissed" } | { type: "ChatQueuedSendStarted" } | { type: "ChatQueuedHttpDone"; diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index e88419e4..5bd9bafe 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -73,6 +73,33 @@ export const loadMoreHistoryActor = fromPromise< }; }); +export const unlockHistoryActor = fromPromise<{ + unlocked: boolean; + reason: string; + shortfallCredits: number; + messages: UiMessage[]; + hasMore: boolean; + newOffset: number; +}>(async () => { + const unlockResult = await chatRepo.unlockHistory(); + if (Result.isErr(unlockResult)) { + log.error("[chat-machine] unlockHistoryActor failed", { + error: unlockResult.error, + }); + throw unlockResult.error; + } + + const history = await readAndSyncHistory(); + return { + unlocked: unlockResult.data.unlocked, + reason: unlockResult.data.reason, + shortfallCredits: unlockResult.data.shortfallCredits, + messages: history.messages, + hasMore: history.hasMore, + newOffset: history.newOffset, + }; +}); + export const httpMessageQueueActor = fromCallback( ({ sendBack, receive }) => { return createMessageQueueActor(sendBack, receive); diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index f5c5ca77..0c733099 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -62,6 +62,19 @@ export function localMessagesToUi( })); } +export function countLockedHistoryMessages(messages: readonly UiMessage[]): number { + return messages.filter(isUnlockableLockedMessage).length; +} + +function isUnlockableLockedMessage(message: UiMessage): boolean { + if (!message.isFromAI || message.locked !== true) return false; + if (message.imagePaywalled === true) return true; + return ( + message.lockReason === "private_message" || + message.lockReason === "voice_message" + ); +} + function messageDateFromCreatedAt(createdAt: string): string { const parsed = new Date(createdAt); if (Number.isNaN(parsed.getTime())) return todayString(); @@ -213,6 +226,32 @@ export function applyHttpSendOutput( }; } +export function applyHistoryLoadedOutput( + output: { + messages: UiMessage[]; + hasMore: boolean; + newOffset: number; + }, +): Pick< + ChatState, + "messages" | "hasMore" | "historyOffset" | "historyLoaded" +> { + return { + messages: output.messages, + hasMore: output.hasMore, + historyOffset: output.newOffset, + historyLoaded: true, + }; +} + +export function shouldAutoUnlockHistory(messages: readonly UiMessage[]): boolean { + return countLockedHistoryMessages(messages) === 1; +} + +export function shouldPromptUnlockHistory(messages: readonly UiMessage[]): boolean { + return countLockedHistoryMessages(messages) > 1; +} + export function normalizeLockDetail( detail: Record | null, ): ChatState["upgradeDetail"] { diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index c37c4c6e..617c33cb 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -42,14 +42,19 @@ import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; import { applyHttpSendOutput, + applyHistoryLoadedOutput, beginPendingReply, + countLockedHistoryMessages, finishPendingReply, + shouldAutoUnlockHistory, + shouldPromptUnlockHistory, } from "./chat-machine.helpers"; import { loadHistoryActor, sendMessageHttpActor, loadMoreHistoryActor, httpMessageQueueActor, + unlockHistoryActor, } from "./chat-machine.actors"; const log = new Logger("StoresChatChatMachine"); @@ -72,6 +77,7 @@ export const chatMachine = setup({ sendMessageHttp: sendMessageHttpActor, loadMoreHistory: loadMoreHistoryActor, httpMessageQueue: httpMessageQueueActor, + unlockHistory: unlockHistoryActor, }, actions: { enqueueMessage: sendTo("messageQueue", ({ event }) => event), @@ -211,6 +217,37 @@ export const chatMachine = setup({ if (event.type !== "ChatQueuedHttpDone") return {}; return applyHttpSendOutput(context, event.output); }), + + markPaymentUnlockPending: assign(() => ({ + paymentUnlockPending: true, + unlockHistoryError: null, + })), + + showUnlockHistoryPrompt: assign(({ context }) => ({ + paymentUnlockPending: false, + unlockHistoryPromptVisible: true, + lockedHistoryCount: countLockedHistoryMessages(context.messages), + unlockHistoryError: null, + })), + + dismissUnlockHistoryPrompt: assign(() => ({ + paymentUnlockPending: false, + unlockHistoryPromptVisible: false, + unlockHistoryError: null, + })), + + markUnlockHistoryStarted: assign(() => ({ + paymentUnlockPending: false, + unlockHistoryPromptVisible: false, + isUnlockingHistory: true, + unlockHistoryError: null, + })), + + markUnlockHistoryFailed: assign(() => ({ + isUnlockingHistory: false, + unlockHistoryPromptVisible: true, + unlockHistoryError: "Failed to unlock messages. Please try again.", + })), }, }).createMachine({ id: "chat", @@ -264,12 +301,9 @@ export const chatMachine = setup({ id: "loadHistory", src: "loadHistory", onDone: { - actions: assign(({ event }) => ({ - messages: event.output.messages, - hasMore: event.output.hasMore, - historyOffset: event.output.newOffset, - historyLoaded: true, - })), + actions: assign(({ event }) => + applyHistoryLoadedOutput(event.output), + ), }, onError: { // 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空) @@ -337,22 +371,80 @@ export const chatMachine = setup({ ChatQueuedSendError: { actions: "appendQueuedSendErrorMessage", }, + ChatPaymentSucceeded: [ + { + guard: ({ context }) => + context.historyLoaded && shouldAutoUnlockHistory(context.messages), + target: ".unlockingHistory", + actions: "markUnlockHistoryStarted", + }, + { + guard: ({ context }) => + context.historyLoaded && + shouldPromptUnlockHistory(context.messages), + actions: "showUnlockHistoryPrompt", + }, + { + guard: ({ context }) => !context.historyLoaded, + actions: "markPaymentUnlockPending", + }, + { + guard: ({ context }) => context.historyLoaded, + actions: "dismissUnlockHistoryPrompt", + }, + ], + ChatUnlockHistoryConfirmed: { + target: ".unlockingHistory", + actions: "markUnlockHistoryStarted", + }, + ChatUnlockHistoryDismissed: { + actions: "dismissUnlockHistoryPrompt", + }, }, initial: "initializing", states: { initializing: { invoke: { src: "loadHistory", - onDone: { - target: "ready", - actions: assign(({ event }) => ({ - messages: event.output.messages, - isLoadingMore: false, - hasMore: event.output.hasMore, - historyOffset: event.output.newOffset, - historyLoaded: true, - })), - }, + onDone: [ + { + guard: ({ context, event }) => + context.paymentUnlockPending && + shouldAutoUnlockHistory(event.output.messages), + target: "unlockingHistory", + actions: [ + assign(({ event }) => ({ + ...applyHistoryLoadedOutput(event.output), + isLoadingMore: false, + })), + "markUnlockHistoryStarted", + ], + }, + { + guard: ({ context, event }) => + context.paymentUnlockPending && + shouldPromptUnlockHistory(event.output.messages), + target: "ready", + actions: assign(({ event }) => ({ + ...applyHistoryLoadedOutput(event.output), + isLoadingMore: false, + paymentUnlockPending: false, + unlockHistoryPromptVisible: true, + lockedHistoryCount: countLockedHistoryMessages( + event.output.messages, + ), + unlockHistoryError: null, + })), + }, + { + target: "ready", + actions: assign(({ event }) => ({ + ...applyHistoryLoadedOutput(event.output), + isLoadingMore: false, + paymentUnlockPending: false, + })), + }, + ], onError: { target: "ready", actions: assign({ @@ -413,6 +505,40 @@ export const chatMachine = setup({ }, }, }, + unlockingHistory: { + invoke: { + id: "unlockHistory", + src: "unlockHistory", + onDone: { + target: "ready", + actions: assign(({ event }) => { + const lockedHistoryCount = countLockedHistoryMessages( + event.output.messages, + ); + const insufficientBalance = + !event.output.unlocked && + event.output.reason === "insufficient_balance"; + return { + messages: event.output.messages, + hasMore: event.output.hasMore, + historyOffset: event.output.newOffset, + historyLoaded: true, + paymentUnlockPending: false, + unlockHistoryPromptVisible: insufficientBalance, + lockedHistoryCount, + isUnlockingHistory: false, + unlockHistoryError: insufficientBalance + ? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.` + : null, + }; + }), + }, + onError: { + target: "ready", + actions: "markUnlockHistoryFailed", + }, + }, + }, }, }, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 26efd731..3d1baa78 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -23,6 +23,11 @@ export interface ChatState { * - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事) */ historyLoaded: boolean; + paymentUnlockPending: boolean; + unlockHistoryPromptVisible: boolean; + lockedHistoryCount: number; + isUnlockingHistory: boolean; + unlockHistoryError: string | null; } export const initialState: ChatState = { @@ -37,4 +42,9 @@ export const initialState: ChatState = { hasMore: true, historyOffset: 0, historyLoaded: false, + paymentUnlockPending: false, + unlockHistoryPromptVisible: false, + lockedHistoryCount: 0, + isUnlockingHistory: false, + unlockHistoryError: null, }; diff --git a/src/stores/payment/index.ts b/src/stores/payment/index.ts index f80c359b..35f921b8 100644 --- a/src/stores/payment/index.ts +++ b/src/stores/payment/index.ts @@ -6,4 +6,5 @@ export * from "./payment-context"; export * from "./payment-events"; export * from "./payment-machine"; export * from "./payment-machine.actors"; +export * from "./payment-success-sync"; export * from "./payment-state"; diff --git a/src/stores/payment/payment-success-sync.tsx b/src/stores/payment/payment-success-sync.tsx new file mode 100644 index 00000000..205b5ccc --- /dev/null +++ b/src/stores/payment/payment-success-sync.tsx @@ -0,0 +1,41 @@ +"use client"; + +/** + * Payment → User / Chat 同步器 + * + * 支付成功是跨模块事件: + * - User 需要重新拉取 profile + entitlements,刷新 isVip / creditBalance。 + * - Chat 需要根据历史锁定消息数量决定直接解锁或弹窗确认。 + */ +import { useEffect, useRef } from "react"; + +import { useChatDispatch } from "@/stores/chat/chat-context"; +import { useUserDispatch } from "@/stores/user/user-context"; + +import { usePaymentState } from "./payment-context"; + +export function PaymentSuccessSync() { + const paymentState = usePaymentState(); + const userDispatch = useUserDispatch(); + const chatDispatch = useChatDispatch(); + const lastPaidKeyRef = useRef(null); + + useEffect(() => { + if (!paymentState.isPaid || !paymentState.currentOrderId) return; + + const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`; + if (lastPaidKeyRef.current === paidKey) return; + lastPaidKeyRef.current = paidKey; + + userDispatch({ type: "UserFetch" }); + chatDispatch({ type: "ChatPaymentSucceeded" }); + }, [ + chatDispatch, + paymentState.currentOrderId, + paymentState.isPaid, + paymentState.orderStatus, + userDispatch, + ]); + + return null; +}