refactor: scope providers by route and use XState selectors
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||
import { shallowEqual } from "@xstate/react";
|
||||
|
||||
import {
|
||||
useChatDispatch,
|
||||
useChatSelector,
|
||||
} from "@/stores/chat/chat-context";
|
||||
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
||||
|
||||
import { HistoryUnlockDialog } from "./history-unlock-dialog";
|
||||
@@ -19,7 +24,15 @@ export function ChatUnlockDialogs({
|
||||
onConfirmInsufficientCreditsDialog,
|
||||
includeHistoryUnlock = true,
|
||||
}: ChatUnlockDialogsProps) {
|
||||
const chatState = useChatState();
|
||||
const chatState = useChatSelector(
|
||||
(state) => ({
|
||||
isUnlockingHistory: state.context.isUnlockingHistory,
|
||||
lockedHistoryCount: state.context.lockedHistoryCount,
|
||||
unlockHistoryError: state.context.unlockHistoryError,
|
||||
unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const chatDispatch = useChatDispatch();
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - AI:[Avatar] [Content] [占位 spacer]
|
||||
* - 用户:[占位 spacer] [Content] [Avatar]
|
||||
*/
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
|
||||
import { MessageAvatar } from "./message-avatar";
|
||||
import { MessageContent } from "./message-content";
|
||||
@@ -49,7 +49,7 @@ export function MessageBubble({
|
||||
onUnlockImageMessage,
|
||||
onOpenImage,
|
||||
}: MessageBubbleProps) {
|
||||
const { avatarUrl } = useUserState();
|
||||
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
|
||||
|
||||
if (isFromAI) {
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { LoginStatus } from "@/data/dto/auth";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
|
||||
import {
|
||||
getInsufficientCreditsMessageLimitView,
|
||||
@@ -31,7 +31,7 @@ export function useChatMessageLimitBanner({
|
||||
loginStatus,
|
||||
}: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView {
|
||||
const navigator = useAppNavigator();
|
||||
const userState = useUserState();
|
||||
const isVip = useUserSelector((state) => state.context.isVip);
|
||||
const view = getInsufficientCreditsMessageLimitView(loginStatus);
|
||||
|
||||
function unlock(): void {
|
||||
@@ -41,7 +41,7 @@ export function useChatMessageLimitBanner({
|
||||
}
|
||||
|
||||
navigator.openSubscription({
|
||||
type: getInsufficientCreditsSubscriptionType(userState.isVip),
|
||||
type: getInsufficientCreditsSubscriptionType(isVip),
|
||||
returnTo: "chat",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { shallowEqual } from "@xstate/react";
|
||||
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import {
|
||||
@@ -11,9 +12,12 @@ import {
|
||||
type PendingChatPromotion,
|
||||
} from "@/lib/navigation/chat_unlock_session";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||
import {
|
||||
useChatDispatch,
|
||||
useChatSelector,
|
||||
} from "@/stores/chat/chat-context";
|
||||
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
|
||||
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
|
||||
|
||||
@@ -55,8 +59,14 @@ export function useChatUnlockNavigationFlow({
|
||||
enabled = true,
|
||||
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
||||
const navigator = useAppNavigator();
|
||||
const userState = useUserState();
|
||||
const chatState = useChatState();
|
||||
const isVip = useUserSelector((state) => state.context.isVip);
|
||||
const chatState = useChatSelector(
|
||||
(state) => ({
|
||||
historyLoaded: state.context.historyLoaded,
|
||||
unlockPaywallRequest: state.context.unlockPaywallRequest,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const chatDispatch = useChatDispatch();
|
||||
const unlockPaywallRequest = getScopedUnlockPaywallRequest({
|
||||
request: chatState.unlockPaywallRequest,
|
||||
@@ -161,7 +171,7 @@ export function useChatUnlockNavigationFlow({
|
||||
clientLockId: unlockPaywallRequest.clientLockId,
|
||||
promotion: unlockPaywallRequest.promotion,
|
||||
returnUrl,
|
||||
type: getInsufficientCreditsSubscriptionType(userState.isVip),
|
||||
type: getInsufficientCreditsSubscriptionType(isVip),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { shallowEqual } from "@xstate/react";
|
||||
|
||||
import type { LoginStatus } from "@/data/dto/auth";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { usePaymentDispatch, usePaymentState } from "@/stores/payment";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
import { usePaymentDispatch, usePaymentSelector } from "@/stores/payment";
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||
import {
|
||||
getFirstRechargeOfferBannerDismissed,
|
||||
@@ -55,12 +56,24 @@ export function useFirstRechargeOfferBanner({
|
||||
loginStatus,
|
||||
}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput {
|
||||
const navigator = useAppNavigator();
|
||||
const payment = usePaymentState();
|
||||
const payment = usePaymentSelector(
|
||||
(state) => ({
|
||||
isFirstRecharge: state.context.isFirstRecharge,
|
||||
status: String(state.value),
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const userState = useUserState();
|
||||
const user = useUserSelector(
|
||||
(state) => ({
|
||||
countryCode: state.context.currentUser?.countryCode,
|
||||
id: state.context.currentUser?.id ?? null,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const isAuthenticatedUser =
|
||||
loginStatus !== "notLoggedIn" && loginStatus !== "guest";
|
||||
const userId = userState.currentUser?.id ?? null;
|
||||
const userId = user.id;
|
||||
const hasUserIdentity = !isAuthenticatedUser || Boolean(userId);
|
||||
const [dismissalState, setDismissalState] = useState<DismissalState>(() => ({
|
||||
userId,
|
||||
@@ -73,14 +86,14 @@ export function useFirstRechargeOfferBanner({
|
||||
if (!isAuthenticatedUser) return;
|
||||
if (payment.status !== "idle") return;
|
||||
const defaultPayChannel = getDefaultPayChannelForCountryCode(
|
||||
userState.currentUser?.countryCode,
|
||||
user.countryCode,
|
||||
);
|
||||
paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel });
|
||||
}, [
|
||||
payment.status,
|
||||
paymentDispatch,
|
||||
isAuthenticatedUser,
|
||||
userState.currentUser?.countryCode,
|
||||
user.countryCode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ChatRouteProviders } from "@/providers/chat-route-providers";
|
||||
|
||||
export default function ChatLayout({ children }: { children: ReactNode }) {
|
||||
return <ChatRouteProviders>{children}</ChatRouteProviders>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider";
|
||||
|
||||
export default function PrivateRoomLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <PrivateRoomRouteProvider>{children}</PrivateRoomRouteProvider>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
|
||||
|
||||
export default function SubscriptionLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <PaymentRouteProvider>{children}</PaymentRouteProvider>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
|
||||
|
||||
export default function TipLayout({ children }: { children: ReactNode }) {
|
||||
return <PaymentRouteProvider>{children}</PaymentRouteProvider>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ChatProvider } from "@/stores/chat/chat-context";
|
||||
import { PaymentProvider } from "@/stores/payment";
|
||||
import {
|
||||
ChatAuthSync,
|
||||
ChatPaymentSuccessSync,
|
||||
PaymentSuccessSync,
|
||||
} from "@/stores/sync";
|
||||
|
||||
export function ChatRouteProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PaymentProvider>
|
||||
<PaymentSuccessSync />
|
||||
<ChatProvider>
|
||||
<ChatAuthSync />
|
||||
<ChatPaymentSuccessSync />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</PaymentProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PaymentProvider } from "@/stores/payment";
|
||||
import { PaymentSuccessSync } from "@/stores/sync";
|
||||
|
||||
export function PaymentRouteProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PaymentProvider>
|
||||
<PaymentSuccessSync />
|
||||
{children}
|
||||
</PaymentProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PrivateRoomProvider } from "@/stores/private-room";
|
||||
|
||||
export function PrivateRoomRouteProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <PrivateRoomProvider>{children}</PrivateRoomProvider>;
|
||||
}
|
||||
@@ -3,15 +3,8 @@
|
||||
/**
|
||||
* 根级 Client Providers 包装
|
||||
*
|
||||
* 把所有功能 Provider 串起来:
|
||||
* AuthProvider → UserProvider → PaymentProvider → ChatProvider → PrivateRoomProvider
|
||||
* + AuthStatusChecker(启动时一次:派发 AuthInit)
|
||||
* + OAuthSessionSync (持续监听 NextAuth session → auth machine)
|
||||
* + UserAuthSync (auth 登录态恢复后 hydrate user machine)
|
||||
* + ChatAuthSync (持续监听 auth loginStatus → chat machine)
|
||||
*
|
||||
* 它们始终挂载,保证各页面直接 `use*State()` / `use*Dispatch()` 即可,
|
||||
* 无需在每个页面单独包裹。
|
||||
* 这里只保留跨路由的 Auth、User 和 viewport Provider。Chat、Payment、
|
||||
* PrivateRoom 状态机由对应路由 layout 按需挂载。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
@@ -21,14 +14,7 @@ import { AppNavigationGuard } from "@/router/app-navigation-guard";
|
||||
import { AuthProvider } from "@/stores/auth/auth-context";
|
||||
import { AuthStatusChecker } from "@/stores/auth/auth-status-checker";
|
||||
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
|
||||
import { ChatProvider } from "@/stores/chat/chat-context";
|
||||
import { PaymentProvider } from "@/stores/payment";
|
||||
import { PrivateRoomProvider } from "@/stores/private-room";
|
||||
import {
|
||||
ChatAuthSync,
|
||||
PaymentSuccessSync,
|
||||
UserAuthSync,
|
||||
} from "@/stores/sync";
|
||||
import { UserAuthSync } from "@/stores/sync";
|
||||
import { UserProvider } from "@/stores/user/user-context";
|
||||
|
||||
export interface RootProvidersProps {
|
||||
@@ -39,24 +25,13 @@ export function RootProviders({ children }: RootProvidersProps) {
|
||||
return (
|
||||
<ViewportCssVarsProvider>
|
||||
<AuthProvider>
|
||||
{/* AuthStatusChecker 必须在 AuthProvider 内部(依赖 useAuthDispatch),
|
||||
* 必须在 UserProvider/ChatProvider 外部(它们可能在 init 完成前就 mount) */}
|
||||
<AuthStatusChecker />
|
||||
{/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */}
|
||||
<OAuthSessionSync />
|
||||
<AppNavigationGuard />
|
||||
<UserProvider>
|
||||
<UserAuthSync />
|
||||
<PaymentProvider>
|
||||
<ChatProvider>
|
||||
<PrivateRoomProvider>
|
||||
<ChatAuthSync />
|
||||
<PaymentSuccessSync />
|
||||
{children}
|
||||
<div id="toast-portal" />
|
||||
</PrivateRoomProvider>
|
||||
</ChatProvider>
|
||||
</PaymentProvider>
|
||||
</UserProvider>
|
||||
</AuthProvider>
|
||||
</ViewportCssVarsProvider>
|
||||
|
||||
@@ -2,13 +2,24 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { shallowEqual } from "@xstate/react";
|
||||
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import {
|
||||
selectAuthIsLoading,
|
||||
useAuthSelector,
|
||||
} from "@/stores/auth/auth-context";
|
||||
|
||||
import { resolveRouteGuardRedirect } from "./navigation-resolver";
|
||||
|
||||
export function AppNavigationGuard() {
|
||||
const authState = useAuthState();
|
||||
const authState = useAuthSelector(
|
||||
(state) => ({
|
||||
hasInitialized: state.context.hasInitialized,
|
||||
isLoading: selectAuthIsLoading(state),
|
||||
loginStatus: state.context.loginStatus,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
peekSubscriptionExplicitExitUrl,
|
||||
} from "@/lib/navigation/subscription_exit";
|
||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
import { useAuthSelector } from "@/stores/auth/auth-context";
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
|
||||
import {
|
||||
ROUTE_BUILDERS,
|
||||
@@ -47,9 +47,11 @@ export interface AppNavigator {
|
||||
|
||||
export function useAppNavigator(): AppNavigator {
|
||||
const router = useRouter();
|
||||
const authState = useAuthState();
|
||||
const userState = useUserState();
|
||||
const isAuthenticatedUser = resolveIsAuthenticatedUser(authState.loginStatus);
|
||||
const loginStatus = useAuthSelector((state) => state.context.loginStatus);
|
||||
const countryCode = useUserSelector(
|
||||
(state) => state.context.currentUser?.countryCode,
|
||||
);
|
||||
const isAuthenticatedUser = resolveIsAuthenticatedUser(loginStatus);
|
||||
|
||||
const push = useCallback((href: string, options?: { scroll?: boolean }): void => {
|
||||
router.push(href, options);
|
||||
@@ -77,8 +79,8 @@ export function useAppNavigator(): AppNavigator {
|
||||
}, [router]);
|
||||
|
||||
const getDefaultPayChannel = useCallback(() => {
|
||||
return getDefaultPayChannelForCountryCode(userState.currentUser?.countryCode);
|
||||
}, [userState.currentUser?.countryCode]);
|
||||
return getDefaultPayChannelForCountryCode(countryCode);
|
||||
}, [countryCode]);
|
||||
|
||||
const openSubscription = useCallback(
|
||||
({
|
||||
@@ -93,7 +95,7 @@ export function useAppNavigator(): AppNavigator {
|
||||
});
|
||||
|
||||
const nextUrl = resolveAuthenticatedNavigation({
|
||||
loginStatus: authState.loginStatus,
|
||||
loginStatus,
|
||||
targetUrl: target,
|
||||
});
|
||||
|
||||
@@ -103,7 +105,7 @@ export function useAppNavigator(): AppNavigator {
|
||||
}
|
||||
router.push(nextUrl);
|
||||
},
|
||||
[authState.loginStatus, getDefaultPayChannel, router],
|
||||
[getDefaultPayChannel, loginStatus, router],
|
||||
);
|
||||
|
||||
const startMessageUnlock = useCallback(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AuthProvider,
|
||||
useAuthDispatch,
|
||||
useAuthSelector,
|
||||
} from "@/stores/auth/auth-context";
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
describe("AuthProvider selectors", () => {
|
||||
it("does not rerender a login-status subscriber for panel-only changes", () => {
|
||||
let renderCount = 0;
|
||||
|
||||
function Probe() {
|
||||
const loginStatus = useAuthSelector(
|
||||
(state) => state.context.loginStatus,
|
||||
);
|
||||
const dispatch = useAuthDispatch();
|
||||
renderCount += 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({ type: "AuthPanelModeChanged", mode: "email" })
|
||||
}
|
||||
>
|
||||
{loginStatus}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<AuthProvider>
|
||||
<Probe />
|
||||
</AuthProvider>,
|
||||
);
|
||||
});
|
||||
expect(renderCount).toBe(1);
|
||||
|
||||
act(() => {
|
||||
container?.querySelector("button")?.click();
|
||||
});
|
||||
|
||||
expect(renderCount).toBe(1);
|
||||
expect(container.textContent).toBe("notLoggedIn");
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,10 @@
|
||||
"use client";
|
||||
/**
|
||||
* AuthContext:基于 XState v5 的 React Context Provider
|
||||
*
|
||||
* 业务事件(`Auth*Submitted`)通过 `useMachine(authMachine).send()` 派发。
|
||||
* XState 自动处理异步副作用(invoke + fromPromise actors)。
|
||||
*
|
||||
* 兼容性:保持原 `useAuthState()` / `useAuthDispatch()` API,
|
||||
* 内部将 XState 状态机快照映射回原 `AuthState` 形状。
|
||||
* AuthContext:基于 XState actor context 和 selector 的 React Provider。
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import type { Dispatch, ReactNode } from "react";
|
||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||
import type { SnapshotFrom } from "xstate";
|
||||
|
||||
import { authMachine } from "./auth-machine";
|
||||
import type { AuthEvent, AuthState as MachineContext } from "./auth-machine";
|
||||
@@ -33,23 +22,46 @@ interface AuthState {
|
||||
hasInitialized: MachineContext["hasInitialized"];
|
||||
}
|
||||
|
||||
const AuthStateCtx = createContext<AuthState | null>(null);
|
||||
const AuthDispatchCtx = createContext<Dispatch<AuthEvent> | null>(null);
|
||||
type AuthSnapshot = SnapshotFrom<typeof authMachine>;
|
||||
type AuthSelector<T> = (snapshot: AuthSnapshot) => T;
|
||||
|
||||
const AuthActorContext = createActorContext(authMachine);
|
||||
|
||||
export interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [state, send] = useMachine(authMachine);
|
||||
return <AuthActorContext.Provider>{children}</AuthActorContext.Provider>;
|
||||
}
|
||||
|
||||
// 映射 XState 状态机快照 → 原 AuthState 形状
|
||||
const authState = useMemo<AuthState>(
|
||||
() => ({
|
||||
export function useAuthState(): AuthState {
|
||||
return useAuthSelector(selectAuthState, shallowEqual);
|
||||
}
|
||||
|
||||
export function useAuthDispatch(): Dispatch<AuthEvent> {
|
||||
return AuthActorContext.useActorRef().send;
|
||||
}
|
||||
|
||||
export function useAuthSelector<T>(
|
||||
selector: AuthSelector<T>,
|
||||
compare?: (previous: T, next: T) => boolean,
|
||||
): T {
|
||||
return AuthActorContext.useSelector(selector, compare);
|
||||
}
|
||||
|
||||
function selectAuthState(state: AuthSnapshot): AuthState {
|
||||
return {
|
||||
authPanelMode: state.context.authPanelMode,
|
||||
// isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/
|
||||
// OAuth 回调后端 sync / 显式游客登录
|
||||
isLoading:
|
||||
isLoading: selectAuthIsLoading(state),
|
||||
errorMessage: state.context.errorMessage,
|
||||
loginStatus: state.context.loginStatus,
|
||||
hasInitialized: state.context.hasInitialized,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectAuthIsLoading(state: AuthSnapshot): boolean {
|
||||
return (
|
||||
state.matches("loadingEmailLogin") ||
|
||||
state.matches("loadingEmailRegister") ||
|
||||
state.matches("loadingOAuth") ||
|
||||
@@ -57,30 +69,6 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
state.matches("loggingOut") ||
|
||||
state.matches("syncingGoogleBackend") ||
|
||||
state.matches("syncingFacebookBackend") ||
|
||||
state.matches("loadingGuestLogin"),
|
||||
errorMessage: state.context.errorMessage,
|
||||
loginStatus: state.context.loginStatus,
|
||||
hasInitialized: state.context.hasInitialized,
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthStateCtx.Provider value={authState}>
|
||||
<AuthDispatchCtx.Provider value={send}>{children}</AuthDispatchCtx.Provider>
|
||||
</AuthStateCtx.Provider>
|
||||
state.matches("loadingGuestLogin")
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthState(): AuthState {
|
||||
const ctx = useContext(AuthStateCtx);
|
||||
if (!ctx) throw new Error("useAuthState must be used inside <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useAuthDispatch(): Dispatch<AuthEvent> {
|
||||
const ctx = useContext(AuthDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useAuthDispatch must be used inside <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { type Dispatch, type ReactNode, useMemo } from "react";
|
||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||
import type { SnapshotFrom } from "xstate";
|
||||
|
||||
import { chatMachine } from "./chat-machine";
|
||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
@@ -38,23 +33,43 @@ interface ChatState {
|
||||
unlockPaywallRequest: MachineContext["unlockPaywallRequest"];
|
||||
}
|
||||
|
||||
const ChatStateCtx = createContext<ChatState | null>(null);
|
||||
const ChatDispatchCtx = createContext<Dispatch<ChatEvent> | null>(null);
|
||||
type ChatSnapshot = SnapshotFrom<typeof chatMachine>;
|
||||
type ChatSelector<T> = (snapshot: ChatSnapshot) => T;
|
||||
|
||||
const ChatActorContext = createActorContext(chatMachine);
|
||||
|
||||
export interface ChatProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ChatProvider({ children }: ChatProviderProps) {
|
||||
const [state, send] = useMachine(chatMachine, {});
|
||||
return <ChatActorContext.Provider>{children}</ChatActorContext.Provider>;
|
||||
}
|
||||
|
||||
// 映射 XState 状态机快照 → 原 ChatState 形状
|
||||
const chatState = useMemo<ChatState>(
|
||||
() => ({
|
||||
messages: appendPromotionMessage(
|
||||
state.context.messages,
|
||||
state.context.promotion,
|
||||
),
|
||||
export function useChatState(): ChatState {
|
||||
const selected = useChatSelector(selectChatState, shallowEqual);
|
||||
const messages = useMemo(
|
||||
() => appendPromotionMessage(selected.historyMessages, selected.promotion),
|
||||
[selected.historyMessages, selected.promotion],
|
||||
);
|
||||
return useMemo(() => ({ ...selected, messages }), [messages, selected]);
|
||||
}
|
||||
|
||||
export function useChatDispatch(): Dispatch<ChatEvent> {
|
||||
return ChatActorContext.useActorRef().send;
|
||||
}
|
||||
|
||||
export function useChatSelector<T>(
|
||||
selector: ChatSelector<T>,
|
||||
compare?: (previous: T, next: T) => boolean,
|
||||
): T {
|
||||
return ChatActorContext.useSelector(selector, compare);
|
||||
}
|
||||
|
||||
type SelectedChatState = Omit<ChatState, "messages">;
|
||||
|
||||
function selectChatState(state: ChatSnapshot): SelectedChatState {
|
||||
return {
|
||||
historyMessages: state.context.messages,
|
||||
promotion: state.context.promotion,
|
||||
isReplyingAI: state.context.isReplyingAI,
|
||||
@@ -69,26 +84,5 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||
unlockingMessageId: state.context.unlockingMessageId,
|
||||
unlockMessageError: state.context.unlockMessageError,
|
||||
unlockPaywallRequest: state.context.unlockPaywallRequest,
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatStateCtx.Provider value={chatState}>
|
||||
<ChatDispatchCtx.Provider value={send}>{children}</ChatDispatchCtx.Provider>
|
||||
</ChatStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatState(): ChatState {
|
||||
const ctx = useContext(ChatStateCtx);
|
||||
if (!ctx) throw new Error("useChatState must be used inside <ChatProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useChatDispatch(): Dispatch<ChatEvent> {
|
||||
const ctx = useContext(ChatDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useChatDispatch must be used inside <ChatProvider>");
|
||||
return ctx;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
/**
|
||||
* PaymentContext:基于 XState v5 的 React Context Provider
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import type { Dispatch, ReactNode } from "react";
|
||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||
import type { SnapshotFrom } from "xstate";
|
||||
|
||||
import { paymentMachine } from "./payment-machine";
|
||||
import type {
|
||||
@@ -37,18 +32,36 @@ export interface PaymentContextState {
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
const PaymentStateCtx = createContext<PaymentContextState | null>(null);
|
||||
const PaymentDispatchCtx = createContext<Dispatch<PaymentEvent> | null>(null);
|
||||
type PaymentSnapshot = SnapshotFrom<typeof paymentMachine>;
|
||||
type PaymentSelector<T> = (snapshot: PaymentSnapshot) => T;
|
||||
|
||||
const PaymentActorContext = createActorContext(paymentMachine);
|
||||
|
||||
export interface PaymentProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PaymentProvider({ children }: PaymentProviderProps) {
|
||||
const [state, send] = useMachine(paymentMachine);
|
||||
return <PaymentActorContext.Provider>{children}</PaymentActorContext.Provider>;
|
||||
}
|
||||
|
||||
const paymentState = useMemo<PaymentContextState>(
|
||||
() => ({
|
||||
export function usePaymentState(): PaymentContextState {
|
||||
return usePaymentSelector(selectPaymentState, shallowEqual);
|
||||
}
|
||||
|
||||
export function usePaymentDispatch(): Dispatch<PaymentEvent> {
|
||||
return PaymentActorContext.useActorRef().send;
|
||||
}
|
||||
|
||||
export function usePaymentSelector<T>(
|
||||
selector: PaymentSelector<T>,
|
||||
compare?: (previous: T, next: T) => boolean,
|
||||
): T {
|
||||
return PaymentActorContext.useSelector(selector, compare);
|
||||
}
|
||||
|
||||
function selectPaymentState(state: PaymentSnapshot): PaymentContextState {
|
||||
return {
|
||||
status: String(state.value),
|
||||
plans: state.context.plans,
|
||||
isFirstRecharge: state.context.isFirstRecharge,
|
||||
@@ -69,29 +82,5 @@ export function PaymentProvider({ children }: PaymentProviderProps) {
|
||||
isPollingOrder:
|
||||
state.matches("pollingOrder") || state.matches("waitingForPayment"),
|
||||
isPaid: state.matches("paid"),
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
return (
|
||||
<PaymentStateCtx.Provider value={paymentState}>
|
||||
<PaymentDispatchCtx.Provider value={send}>
|
||||
{children}
|
||||
</PaymentDispatchCtx.Provider>
|
||||
</PaymentStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePaymentState(): PaymentContextState {
|
||||
const ctx = useContext(PaymentStateCtx);
|
||||
if (!ctx)
|
||||
throw new Error("usePaymentState must be used inside <PaymentProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function usePaymentDispatch(): Dispatch<PaymentEvent> {
|
||||
const ctx = useContext(PaymentDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("usePaymentDispatch must be used inside <PaymentProvider>");
|
||||
return ctx;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import type { Dispatch, ReactNode } from "react";
|
||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||
import type { SnapshotFrom } from "xstate";
|
||||
|
||||
import { privateRoomMachine } from "./private-room-machine";
|
||||
import type {
|
||||
@@ -33,20 +28,40 @@ export interface PrivateRoomContextState {
|
||||
isUnlocking: boolean;
|
||||
}
|
||||
|
||||
const PrivateRoomStateCtx = createContext<PrivateRoomContextState | null>(null);
|
||||
const PrivateRoomDispatchCtx = createContext<Dispatch<PrivateRoomEvent> | null>(
|
||||
null,
|
||||
);
|
||||
type PrivateRoomSnapshot = SnapshotFrom<typeof privateRoomMachine>;
|
||||
type PrivateRoomSelector<T> = (snapshot: PrivateRoomSnapshot) => T;
|
||||
|
||||
const PrivateRoomActorContext = createActorContext(privateRoomMachine);
|
||||
|
||||
export interface PrivateRoomProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PrivateRoomProvider({ children }: PrivateRoomProviderProps) {
|
||||
const [state, send] = useMachine(privateRoomMachine);
|
||||
return (
|
||||
<PrivateRoomActorContext.Provider>{children}</PrivateRoomActorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const privateRoomState = useMemo<PrivateRoomContextState>(
|
||||
() => ({
|
||||
export function usePrivateRoomState(): PrivateRoomContextState {
|
||||
return usePrivateRoomSelector(selectPrivateRoomState, shallowEqual);
|
||||
}
|
||||
|
||||
export function usePrivateRoomDispatch(): Dispatch<PrivateRoomEvent> {
|
||||
return PrivateRoomActorContext.useActorRef().send;
|
||||
}
|
||||
|
||||
export function usePrivateRoomSelector<T>(
|
||||
selector: PrivateRoomSelector<T>,
|
||||
compare?: (previous: T, next: T) => boolean,
|
||||
): T {
|
||||
return PrivateRoomActorContext.useSelector(selector, compare);
|
||||
}
|
||||
|
||||
function selectPrivateRoomState(
|
||||
state: PrivateRoomSnapshot,
|
||||
): PrivateRoomContextState {
|
||||
return {
|
||||
status: String(state.value),
|
||||
profile: state.context.profile,
|
||||
items: state.context.items,
|
||||
@@ -62,35 +77,5 @@ export function PrivateRoomProvider({ children }: PrivateRoomProviderProps) {
|
||||
isLoading: state.matches("loading"),
|
||||
isLoadingMore: state.matches("loadingMore"),
|
||||
isUnlocking: state.matches("unlocking"),
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
return (
|
||||
<PrivateRoomStateCtx.Provider value={privateRoomState}>
|
||||
<PrivateRoomDispatchCtx.Provider value={send}>
|
||||
{children}
|
||||
</PrivateRoomDispatchCtx.Provider>
|
||||
</PrivateRoomStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePrivateRoomState(): PrivateRoomContextState {
|
||||
const ctx = useContext(PrivateRoomStateCtx);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"usePrivateRoomState must be used inside <PrivateRoomProvider>",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function usePrivateRoomDispatch(): Dispatch<PrivateRoomEvent> {
|
||||
const ctx = useContext(PrivateRoomDispatchCtx);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"usePrivateRoomDispatch must be used inside <PrivateRoomProvider>",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@
|
||||
* 不再承担全局状态同步职责。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { shallowEqual } from "@xstate/react";
|
||||
|
||||
import { AuthStorage } from "@/data/storage/auth";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import {
|
||||
selectAuthIsLoading,
|
||||
useAuthSelector,
|
||||
} from "@/stores/auth/auth-context";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
|
||||
export function ChatAuthSync() {
|
||||
const authState = useAuthState();
|
||||
const authState = useAuthSelector(
|
||||
(state) => ({
|
||||
hasInitialized: state.context.hasInitialized,
|
||||
isLoading: selectAuthIsLoading(state),
|
||||
loginStatus: state.context.loginStatus,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const chatDispatch = useChatDispatch();
|
||||
const prevSessionKeyRef = useRef<string | null>(null);
|
||||
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Payment → User / Chat 同步器
|
||||
* Payment → User 同步器
|
||||
*
|
||||
* 支付成功是跨模块事件:
|
||||
* - User 需要重新拉取 profile + entitlements,刷新 isVip / creditBalance。
|
||||
* - Chat 需要根据历史锁定消息数量决定直接解锁或弹窗确认。
|
||||
* 支付成功后刷新用户权益、清理套餐缓存,并消费首充标记。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { shallowEqual } from "@xstate/react";
|
||||
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { useUserDispatch } from "@/stores/user/user-context";
|
||||
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
usePaymentSelector,
|
||||
} from "@/stores/payment/payment-context";
|
||||
import { getPaymentRepository } from "@/data/repositories/payment_repository";
|
||||
|
||||
export function PaymentSuccessSync() {
|
||||
const paymentState = usePaymentState();
|
||||
const paymentState = usePaymentSelector(
|
||||
(state) => ({
|
||||
currentOrderId: state.context.currentOrderId,
|
||||
isPaid: state.matches("paid"),
|
||||
orderStatus: state.context.orderStatus,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const userDispatch = useUserDispatch();
|
||||
const chatDispatch = useChatDispatch();
|
||||
const lastPaidKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,22 +38,13 @@ export function PaymentSuccessSync() {
|
||||
lastPaidKeyRef.current = paidKey;
|
||||
paymentDispatch({ type: "PaymentFirstRechargeConsumed" });
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const syncPaymentSuccess = async () => {
|
||||
await getPaymentRepository().clearCachedPlans();
|
||||
userDispatch({ type: "UserFetch" });
|
||||
if (await hasPendingChatUnlock()) return;
|
||||
if (cancelled) return;
|
||||
chatDispatch({ type: "ChatPaymentSucceeded" });
|
||||
};
|
||||
|
||||
void syncPaymentSuccess();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
chatDispatch,
|
||||
paymentState.currentOrderId,
|
||||
paymentState.isPaid,
|
||||
paymentState.orderStatus,
|
||||
@@ -58,3 +54,42 @@ export function PaymentSuccessSync() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Payment → Chat bridge, mounted only while the chat route is active. */
|
||||
export function ChatPaymentSuccessSync() {
|
||||
const paymentState = usePaymentSelector(
|
||||
(state) => ({
|
||||
currentOrderId: state.context.currentOrderId,
|
||||
isPaid: state.matches("paid"),
|
||||
orderStatus: state.context.orderStatus,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const chatDispatch = useChatDispatch();
|
||||
const lastPaidKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentState.isPaid || !paymentState.currentOrderId) return;
|
||||
|
||||
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
|
||||
if (lastPaidKeyRef.current === paidKey) return;
|
||||
lastPaidKeyRef.current = paidKey;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
if (await hasPendingChatUnlock()) return;
|
||||
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
chatDispatch,
|
||||
paymentState.currentOrderId,
|
||||
paymentState.isPaid,
|
||||
paymentState.orderStatus,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,24 @@
|
||||
* - guest / email / OAuth → 重新 hydrate user store
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { shallowEqual } from "@xstate/react";
|
||||
|
||||
import type { LoginStatus } from "@/data/dto/auth";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import {
|
||||
selectAuthIsLoading,
|
||||
useAuthSelector,
|
||||
} from "@/stores/auth/auth-context";
|
||||
import { useUserDispatch } from "@/stores/user/user-context";
|
||||
|
||||
export function UserAuthSync() {
|
||||
const { hasInitialized, isLoading, loginStatus } = useAuthState();
|
||||
const { hasInitialized, isLoading, loginStatus } = useAuthSelector(
|
||||
(state) => ({
|
||||
hasInitialized: state.context.hasInitialized,
|
||||
isLoading: selectAuthIsLoading(state),
|
||||
loginStatus: state.context.loginStatus,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
const userDispatch = useUserDispatch();
|
||||
const prevLoginStatusRef = useRef<LoginStatus | null>(null);
|
||||
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
"use client";
|
||||
/**
|
||||
* UserContext:基于 XState v5 的 React Context Provider
|
||||
*
|
||||
* 业务事件通过 `useMachine(userMachine).send()` 派发。
|
||||
* XState 自动处理异步副作用(invoke + fromPromise actors)。
|
||||
*
|
||||
* 兼容性:保持原 `useUserState()` / `useUserDispatch()` API。
|
||||
* 内部将 XState 状态机快照映射回原 `UserState` 形状。
|
||||
* UserContext:基于 XState actor context 和 selector 的 React Provider。
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import type { Dispatch, ReactNode } from "react";
|
||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||
import type { SnapshotFrom } from "xstate";
|
||||
|
||||
import { userMachine } from "./user-machine";
|
||||
import type { UserState as MachineContext, UserEvent } from "./user-machine";
|
||||
@@ -31,19 +20,36 @@ interface UserState {
|
||||
creditBalance: number;
|
||||
}
|
||||
|
||||
const UserStateCtx = createContext<UserState | null>(null);
|
||||
const UserDispatchCtx = createContext<Dispatch<UserEvent> | null>(null);
|
||||
type UserSnapshot = SnapshotFrom<typeof userMachine>;
|
||||
type UserSelector<T> = (snapshot: UserSnapshot) => T;
|
||||
|
||||
const UserActorContext = createActorContext(userMachine);
|
||||
|
||||
export interface UserProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function UserProvider({ children }: UserProviderProps) {
|
||||
const [state, send] = useMachine(userMachine);
|
||||
return <UserActorContext.Provider>{children}</UserActorContext.Provider>;
|
||||
}
|
||||
|
||||
// 映射 XState 状态机快照 → 原 UserState 形状
|
||||
const userState = useMemo<UserState>(
|
||||
() => ({
|
||||
export function useUserState(): UserState {
|
||||
return useUserSelector(selectUserState, shallowEqual);
|
||||
}
|
||||
|
||||
export function useUserDispatch(): Dispatch<UserEvent> {
|
||||
return UserActorContext.useActorRef().send;
|
||||
}
|
||||
|
||||
export function useUserSelector<T>(
|
||||
selector: UserSelector<T>,
|
||||
compare?: (previous: T, next: T) => boolean,
|
||||
): T {
|
||||
return UserActorContext.useSelector(selector, compare);
|
||||
}
|
||||
|
||||
function selectUserState(state: UserSnapshot): UserState {
|
||||
return {
|
||||
currentUser: state.context.currentUser,
|
||||
isLoading:
|
||||
state.matches("initializing") ||
|
||||
@@ -53,26 +59,5 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||
avatarUrl: state.context.avatarUrl,
|
||||
isVip: state.context.isVip,
|
||||
creditBalance: state.context.creditBalance,
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
return (
|
||||
<UserStateCtx.Provider value={userState}>
|
||||
<UserDispatchCtx.Provider value={send}>{children}</UserDispatchCtx.Provider>
|
||||
</UserStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserState(): UserState {
|
||||
const ctx = useContext(UserStateCtx);
|
||||
if (!ctx) throw new Error("useUserState must be used inside <UserProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useUserDispatch(): Dispatch<UserEvent> {
|
||||
const ctx = useContext(UserDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useUserDispatch must be used inside <UserProvider>");
|
||||
return ctx;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user