diff --git a/middleware.ts b/proxy.ts similarity index 91% rename from middleware.ts rename to proxy.ts index 643ca86..2ffc349 100644 --- a/middleware.ts +++ b/proxy.ts @@ -1,4 +1,4 @@ -import { NextResponse, type NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import { lotteryApiOrigin } from "./src/lib/lottery-api-base"; import { generateCSP, nonCspSecurityHeaders } from "./src/lib/csp-config"; @@ -42,7 +42,7 @@ async function loadRuntimeOrigins(): Promise { } } -export async function middleware(_request: NextRequest): Promise { +export async function proxy(): Promise { const response = NextResponse.next(); const runtimeOrigins = await loadRuntimeOrigins(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 55a3079..fcde568 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -28,8 +28,6 @@ export const metadata: Metadata = { export const viewport = { width: "device-width", initialScale: 1, - maximumScale: 1, - userScalable: false, viewportFit: "cover", }; diff --git a/src/components/i18n-hydration-provider.tsx b/src/components/i18n-hydration-provider.tsx index e3399dd..a422fc2 100644 --- a/src/components/i18n-hydration-provider.tsx +++ b/src/components/i18n-hydration-provider.tsx @@ -1,19 +1,39 @@ "use client"; -import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { + createContext, + useContext, + useSyncExternalStore, + type ReactNode, +} from "react"; import { syncPreferredLanguage } from "@/i18n"; const I18nHydrationContext = createContext(false); +function subscribeI18nHydration(onStoreChange: () => void): () => void { + queueMicrotask(() => { + syncPreferredLanguage(); + onStoreChange(); + }); + return () => {}; +} + +function getI18nHydratedSnapshot(): boolean { + return true; +} + +function getI18nHydratedServerSnapshot(): boolean { + return false; +} + /** hydration 完成前保持 DEFAULT_LANGUAGE,与 SSR 一致;完成后再同步用户偏好语言。 */ export function I18nHydrationProvider({ children }: { children: ReactNode }) { - const [hydrated, setHydrated] = useState(false); - - useEffect(() => { - syncPreferredLanguage(); - setHydrated(true); - }, []); + const hydrated = useSyncExternalStore( + subscribeI18nHydration, + getI18nHydratedSnapshot, + getI18nHydratedServerSnapshot, + ); return ( {children} diff --git a/src/components/iframe-bridge.tsx b/src/components/iframe-bridge.tsx index 44065b7..247ef64 100644 --- a/src/components/iframe-bridge.tsx +++ b/src/components/iframe-bridge.tsx @@ -10,6 +10,33 @@ import { resolvePostMessageTargetOrigin, } from "@/lib/iframe-origins"; +function sanitizeUrlForParent(href: string): string { + try { + const url = new URL(href); + url.searchParams.delete("token"); + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return href; + } +} + +function resolveSafeInAppPath(path: string): string | null { + if (!path.startsWith("/") || path.startsWith("//")) { + return null; + } + + try { + const url = new URL(path, window.location.origin); + if (url.origin !== window.location.origin) { + return null; + } + + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return null; + } +} + /** * iframe 通信桥接组件 * @@ -49,7 +76,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { */ const notifyReady = useCallback((): void => { sendToParent("READY", { - url: window.location.href, + url: sanitizeUrlForParent(window.location.href), userAgent: navigator.userAgent, }); }, [sendToParent]); @@ -147,7 +174,10 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { // 主站导航请求 case "MAIN_NAVIGATE": if (data.path && typeof data.path === "string") { - window.history.pushState({}, "", data.path); + const nextPath = resolveSafeInAppPath(data.path); + if (nextPath) { + window.history.pushState({}, "", nextPath); + } } break; diff --git a/src/features/hall/hall-betting-grid.tsx b/src/features/hall/hall-betting-grid.tsx index eb2fff6..9e0a09b 100644 --- a/src/features/hall/hall-betting-grid.tsx +++ b/src/features/hall/hall-betting-grid.tsx @@ -33,7 +33,7 @@ import { PLAY_CATALOG_REFRESH_EVENT, type PlayCatalogRefreshSource, } from "@/lib/play-catalog-events"; -import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; + import { isCreditFundingPlayer } from "@/lib/player-funding-mode"; import { cn } from "@/lib/utils"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -499,20 +499,28 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } const clearPlaceTraceId = useCallback(() => { placeTraceIdRef.current = null; }, []); + const catalogSeqRef = useRef(0); + const walletSeqRef = useRef(0); + const loadCatalog = useCallback(async () => { - setCatalogState((s) => (s.kind === "ok" ? s : { kind: "loading" })); + const seq = ++catalogSeqRef.current; + setCatalogState({ kind: "loading" }); try { const data = await getPlayEffective({ currency: currencyParam }); + if (seq !== catalogSeqRef.current) return; setCatalogState({ kind: "ok", data }); } catch (e) { + if (seq !== catalogSeqRef.current) return; const msg = e instanceof LotteryApiBizError ? e.message : t("hall.loadingError"); setCatalogState({ kind: "error", message: msg }); } }, [currencyParam, t]); const refreshWallet = useCallback(async () => { + const seq = ++walletSeqRef.current; try { const wallet = await getWalletBalance({ currency: currencyParam }); + if (seq !== walletSeqRef.current) return; setAvailableMinor(Number(wallet.available_balance ?? 0)); } catch { // 保留上次可用余额,避免短暂失败导致误报余额不足 @@ -526,15 +534,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot } }); }, [loadCatalog, refreshWallet]); - useEffect(() => { - const onCurrencyChange = () => { - void loadCatalog(); - void refreshWallet(); - }; - window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); - return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); - }, [loadCatalog, refreshWallet]); - useEffect(() => { const onCatalogRefresh = (ev: Event) => { void loadCatalog(); diff --git a/src/features/orders/ticket-order-detail-screen.tsx b/src/features/orders/ticket-order-detail-screen.tsx index d98aa59..48a2bce 100644 --- a/src/features/orders/ticket-order-detail-screen.tsx +++ b/src/features/orders/ticket-order-detail-screen.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ChevronRight } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -86,23 +86,28 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { const [siblingItems, setSiblingItems] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const requestSeqRef = useRef(0); const backHref = "/orders"; const backLabel = t("orders.title"); const load = useCallback(async (options?: { silent?: boolean }) => { + const seq = ++requestSeqRef.current; if (!options?.silent) { setLoading(true); } setError(null); try { const row = await getTicketItemDetail(ticketNo); + if (seq !== requestSeqRef.current) return; setData(row); } catch { + if (seq !== requestSeqRef.current) return; setData(null); if (!options?.silent) { setError(t("orders.notFound")); } } finally { + if (seq !== requestSeqRef.current) return; if (!options?.silent) { setLoading(false); } @@ -117,8 +122,14 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { useEffect(() => { const orderNo = (data?.order_no ?? "").trim(); + const siblingSeq = requestSeqRef.current; + if (!orderNo) { - setSiblingItems([]); + queueMicrotask(() => { + if (siblingSeq === requestSeqRef.current) { + setSiblingItems([]); + } + }); return; } @@ -126,12 +137,12 @@ export function TicketOrderDetailScreen({ ticketNo }: { ticketNo: string }) { void (async () => { try { const res = await getTicketItems({ order_no: orderNo, per_page: 50 }); - if (cancelled) return; + if (cancelled || siblingSeq !== requestSeqRef.current) return; setSiblingItems( res.items.filter((row) => row.ticket_no !== ticketNo), ); } catch { - if (!cancelled) { + if (!cancelled && siblingSeq === requestSeqRef.current) { setSiblingItems([]); } } diff --git a/src/features/player/entry-gate.tsx b/src/features/player/entry-gate.tsx index fdcdc64..603558b 100644 --- a/src/features/player/entry-gate.tsx +++ b/src/features/player/entry-gate.tsx @@ -19,6 +19,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import { useTranslation } from "react-i18next"; @@ -79,6 +80,29 @@ function initialSteps(): EntryStep[] { ]; } +let pendingUrlToken = ""; + +function subscribeUrlTokenCapture(onStoreChange: () => void): () => void { + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + const token = url.searchParams.get("token"); + if (token) { + pendingUrlToken = token; + stripSearchParamFromBrowserUrl("token"); + queueMicrotask(onStoreChange); + } + } + return () => {}; +} + +function getUrlTokenSnapshot(): string { + return pendingUrlToken; +} + +function getServerUrlTokenSnapshot(): string { + return ""; +} + function stripSearchParamFromBrowserUrl(name: string): void { if (typeof window === "undefined") return; const url = new URL(window.location.href); @@ -113,6 +137,11 @@ export function EntryGate() { const { t: tc } = useTranslation("common"); const tokenFromUrl = searchParams.get("token") ?? ""; + const capturedUrlToken = useSyncExternalStore( + subscribeUrlTokenCapture, + getUrlTokenSnapshot, + getServerUrlTokenSnapshot, + ); const sessionExpired = searchParams.get("session") === "expired"; const { bearerToken, setBearerToken, setProfile, setCurrencies, clearBearerToken } = @@ -137,11 +166,11 @@ export function EntryGate() { if (!isInIframe()) { if (sessionExpired) return false; - return tokenFromUrl !== ""; + return capturedUrlToken !== "" || tokenFromUrl !== ""; } return true; - }, [sessionExpired, tokenFromUrl]); + }, [capturedUrlToken, sessionExpired, tokenFromUrl]); useEffect(() => { if (gateReady) return; @@ -169,14 +198,15 @@ export function EntryGate() { const [steps, setSteps] = useState(initialSteps()); const entryToken = useMemo(() => { + const urlToken = capturedUrlToken || tokenFromUrl; if (typeof window === "undefined") { - return tokenFromUrl || bearerToken; + return urlToken || bearerToken; } if (!isInIframe()) { - return tokenFromUrl; + return urlToken; } - return tokenFromUrl || bearerToken; - }, [bearerToken, tokenFromUrl]); + return urlToken || bearerToken; + }, [bearerToken, capturedUrlToken, tokenFromUrl]); /** 防止 token 写入 store / URL 剥离后重复触发进场,避免成功/失败页闪一下 */ const entryLifecycleRef = useRef<"idle" | "running" | "done">("idle"); @@ -215,8 +245,10 @@ export function EntryGate() { setPhase("loading"); setFailureDetails([]); - if (tokenFromUrl) { - setBearerToken(tokenFromUrl); + const urlToken = capturedUrlToken || tokenFromUrl; + if (urlToken) { + setBearerToken(urlToken); + pendingUrlToken = ""; stripSearchParamFromBrowserUrl("token"); } @@ -311,6 +343,7 @@ export function EntryGate() { }, ]); }, [ + capturedUrlToken, entryToken, tokenFromUrl, setBearerToken, diff --git a/src/features/wallet/wallet-screen.tsx b/src/features/wallet/wallet-screen.tsx index b5cdd14..2c2192a 100644 --- a/src/features/wallet/wallet-screen.tsx +++ b/src/features/wallet/wallet-screen.tsx @@ -63,11 +63,13 @@ export function WalletScreen() { actionDeepLinkHandledRef.current = true; if (!isCreditPlayer) { - if (action === "transfer-in") { - setTransferInOpen(true); - } else { - setTransferOutOpen(true); - } + queueMicrotask(() => { + if (action === "transfer-in") { + setTransferInOpen(true); + } else { + setTransferOutOpen(true); + } + }); } router.replace("/wallet", { scroll: false }); }, [isCreditPlayer, loading, router, searchParams]); @@ -78,7 +80,7 @@ export function WalletScreen() { if (section !== "logs" && section !== "pending") return; sectionDeepLinkHandledRef.current = true; - const targetId = "wallet-logs"; + const targetId = section === "pending" ? "wallet-pending" : "wallet-logs"; const timer = window.setTimeout(() => { scrollToWalletSection(targetId); router.replace("/wallet", { scroll: false }); @@ -332,6 +334,42 @@ export function WalletScreen() { ) : null} + {!isCreditPlayer && (logs?.pending_reconcile?.length ?? 0) > 0 ? ( +
+

+ {t("wallet.pendingSectionTitle", { defaultValue: "待对账划转" })} +

+

+ {t("wallet.pendingSectionHint", { + defaultValue: "以下划转仍在与主站对账,请稍后刷新查看结果。", + })} +

+
    + {logs?.pending_reconcile.map((item) => ( +
  • +
    + + {item.direction === "in" + ? t("wallet.flow.transfer_in", { defaultValue: "转入" }) + : t("wallet.flow.transfer_out", { defaultValue: "转出" })} + + + {formatMinorAsCurrency(item.amount, item.currency_code || currency)} + +
    +

    {item.transfer_no}

    +
  • + ))} +
+
+ ) : null} +
| null = null; export function useCurrencyCatalog() { const currencies = usePlayerSessionStore((state) => state.currencies); const setCurrencies = usePlayerSessionStore((state) => state.setCurrencies); + const [loadGeneration, setLoadGeneration] = useState(0); useEffect(() => { - if (currencies.length > 0 || inflightCurrencyLoad !== null) { + if (currencies.length > 0 || loadGeneration >= MAX_CURRENCY_LOAD_ATTEMPTS) { + return; + } + + if (inflightCurrencyLoad !== null) { return; } inflightCurrencyLoad = getPublicCurrencies() .then((data) => { - setCurrencies(data.items); + if (data.items.length > 0) { + setCurrencies(data.items); + return; + } + throw new Error("empty_currency_catalog"); }) .catch(() => { - // 币种元数据失败时退回默认 2 位小数,不打断主流程。 + window.setTimeout(() => { + setLoadGeneration((current) => current + 1); + }, RETRY_DELAY_MS); }) .finally(() => { inflightCurrencyLoad = null; }); - }, [currencies.length, setCurrencies]); + }, [currencies.length, setCurrencies, loadGeneration]); return currencies; -} +} \ No newline at end of file diff --git a/src/hooks/use-pull-to-refresh.ts b/src/hooks/use-pull-to-refresh.ts index 26cb10a..f22a55a 100644 --- a/src/hooks/use-pull-to-refresh.ts +++ b/src/hooks/use-pull-to-refresh.ts @@ -30,22 +30,28 @@ export function usePullToRefresh({ const pullingRef = useRef(false); const pullDistanceRef = useRef(0); const onRefreshRef = useRef(onRefresh); + const enabledRef = useRef(enabled); - onRefreshRef.current = onRefresh; + useEffect(() => { + onRefreshRef.current = onRefresh; + }, [onRefresh]); + + useEffect(() => { + enabledRef.current = enabled; + }, [enabled]); const handleTouchStart = useCallback( (e: TouchEvent) => { - if (!enabled || isRefreshing) return; - - // Check if the scroll container is scrolled down + if (!enabledRef.current || isRefreshing) return; + const scrollContainer = document.getElementById("player-scroll-container"); const currentScrollY = scrollContainer ? scrollContainer.scrollTop : window.scrollY; - + if (currentScrollY > 0) return; startYRef.current = e.touches[0].clientY; pullingRef.current = true; }, - [enabled, isRefreshing], + [isRefreshing], ); const handleTouchMove = useCallback( @@ -53,7 +59,6 @@ export function usePullToRefresh({ if (!pullingRef.current || isRefreshing) return; const delta = e.touches[0].clientY - startYRef.current; if (delta <= 0) { - // User is scrolling down — abort pull-to-refresh entirely pullingRef.current = false; if (pullDistanceRef.current !== 0) { pullDistanceRef.current = 0; @@ -61,7 +66,6 @@ export function usePullToRefresh({ } return; } - // Apply rubber-band resistance const resisted = Math.min(delta * 0.4, threshold * 1.5); if (resisted !== pullDistanceRef.current) { pullDistanceRef.current = resisted; @@ -72,6 +76,15 @@ export function usePullToRefresh({ ); const handleTouchEnd = useCallback(async () => { + if (!enabledRef.current) { + pullingRef.current = false; + if (pullDistanceRef.current !== 0) { + pullDistanceRef.current = 0; + setPullDistance(0); + } + return; + } + if (!pullingRef.current) { if (pullDistanceRef.current !== 0) { pullDistanceRef.current = 0; @@ -117,4 +130,4 @@ export function usePullToRefresh({ isRefreshing, pullPercent: Math.min(pullDistance / threshold, 1), }; -} +} \ No newline at end of file diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts index 84466a1..81cbb58 100644 --- a/src/hooks/use-token-refresh.ts +++ b/src/hooks/use-token-refresh.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from "react"; +import { parseJwtExp } from "@/lib/jwt-payload"; import { usePlayerSessionStore } from "@/stores/player-session-store"; import { useErrorStore } from "@/stores/error-store"; import { @@ -14,6 +15,9 @@ const TOKEN_WARNING_THRESHOLD = 60 * 1000; // 1 分钟 /** 最大重试次数 */ const MAX_RETRY = 3; +/** 等待主站回传新 Token 的超时(毫秒) */ +const REFRESH_RESPONSE_TIMEOUT_MS = 5_000; + /** * Token 自动续签 Hook * @@ -38,38 +42,19 @@ export function useTokenRefresh(): { const refreshTimerRef = useRef(null); const retryCountRef = useRef(0); - - /** - * 解析 JWT 的 exp 字段 - * @returns exp 时间戳(秒),解析失败返回 null - */ - const parseTokenExp = useCallback((token: string | null): number | null => { - if (!token) return null; - - try { - // JWT 格式:header.payload.signature - const parts = token.split("."); - if (parts.length !== 3) return null; - - // Base64 解码 payload - const payload = JSON.parse(atob(parts[1])); - return payload.exp ?? null; - } catch { - return null; - } - }, []); + const pendingRefreshRef = useRef(0); /** * 获取 Token 剩余有效时间 * @returns 剩余毫秒数,-1 表示未知 */ const getTokenRemainingTime = useCallback((): number => { - const exp = parseTokenExp(bearerToken); + const exp = parseJwtExp(bearerToken); if (!exp) return -1; const now = Math.floor(Date.now() / 1000); return Math.max(0, (exp - now) * 1000); - }, [bearerToken, parseTokenExp]); + }, [bearerToken]); /** * 检查 Token 是否即将过期(1 分钟内) @@ -109,13 +94,17 @@ export function useTokenRefresh(): { } clearServerError(); - retryCountRef.current++; - - // 向主站请求新 Token + const attemptId = Date.now(); + pendingRefreshRef.current = attemptId; requestParentRefresh(); - // 等待主站响应(通过 postMessage) - // 实际逻辑在下面的 useEffect 中处理 + window.setTimeout(() => { + if (pendingRefreshRef.current !== attemptId) return; + retryCountRef.current += 1; + if (retryCountRef.current >= MAX_RETRY) { + setServerError(true, "Token 刷新失败,请返回主站重新进入"); + } + }, REFRESH_RESPONSE_TIMEOUT_MS); }, [clearServerError, requestParentRefresh, setServerError]); /** @@ -144,8 +133,9 @@ export function useTokenRefresh(): { data.token ) { console.log("[TokenRefresh] Received new token from parent"); + pendingRefreshRef.current = 0; setBearerToken(data.token); - retryCountRef.current = 0; // 重置重试计数 + retryCountRef.current = 0; } // 处理主站通知 Token 即将过期 @@ -171,7 +161,7 @@ export function useTokenRefresh(): { return; } - const exp = parseTokenExp(bearerToken); + const exp = parseJwtExp(bearerToken); if (!exp) return; const now = Date.now(); @@ -203,7 +193,7 @@ export function useTokenRefresh(): { clearTimeout(refreshTimerRef.current); } }; - }, [bearerToken, parseTokenExp, requestParentRefresh]); + }, [bearerToken, requestParentRefresh]); return { refreshToken, diff --git a/src/lib/csp-config.ts b/src/lib/csp-config.ts index 8b9b407..b91dafe 100644 --- a/src/lib/csp-config.ts +++ b/src/lib/csp-config.ts @@ -49,13 +49,17 @@ export function generateCSP(extraParentOrigins: string[] = []): string { .filter((origin): origin is string => origin !== null), ]), ); + const scriptSrc = + process.env.NODE_ENV === "production" + ? ["'self'", "'unsafe-inline'"] + : ["'self'", "'unsafe-inline'", "'unsafe-eval'"]; const directives: Record = { // 默认只允许同源 "default-src": ["'self'"], - // 脚本允许同源和内联(Next.js 需要) - "script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"], + // 开发环境保留 unsafe-eval 供 Next 调试;生产环境禁用 eval。 + "script-src": scriptSrc, // 样式允许同源和内联 "style-src": ["'self'", "'unsafe-inline'"], @@ -107,7 +111,7 @@ export function generateCSP(extraParentOrigins: string[] = []): string { export function isAllowedParent(parentOrigin: string): boolean { const origins = staticAllowedParentOrigins(); if (origins.length === 0) return false; - return origins.some((origin) => parentOrigin.startsWith(origin)); + return origins.includes(parentOrigin); } /** diff --git a/src/lib/jwt-payload.ts b/src/lib/jwt-payload.ts new file mode 100644 index 0000000..bc54e47 --- /dev/null +++ b/src/lib/jwt-payload.ts @@ -0,0 +1,23 @@ +/** 解析 JWT payload(RFC 7519 base64url)。 */ +export function parseJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + + try { + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const json = atob(padded); + const payload = JSON.parse(json) as Record; + return payload; + } catch { + return null; + } +} + +/** 返回 JWT `exp`(秒级 Unix 时间戳),解析失败返回 null。 */ +export function parseJwtExp(token: string | null): number | null { + if (!token) return null; + const payload = parseJwtPayload(token); + const exp = payload?.exp; + return typeof exp === "number" ? exp : null; +} \ No newline at end of file diff --git a/src/lib/lottery-api-proxy.ts b/src/lib/lottery-api-proxy.ts index 8b8e8a3..11983b8 100644 --- a/src/lib/lottery-api-proxy.ts +++ b/src/lib/lottery-api-proxy.ts @@ -50,7 +50,6 @@ export async function proxyLotteryApi( return NextResponse.json( { msg: "Upstream Laravel unreachable", - target, error: error instanceof Error ? error.message : "Unknown error", }, { status: 502 }, diff --git a/src/lib/lottery-http.ts b/src/lib/lottery-http.ts index ac1dbef..ad5638c 100644 --- a/src/lib/lottery-http.ts +++ b/src/lib/lottery-http.ts @@ -61,7 +61,12 @@ function shouldRedirectPlayerSessionExpired(error: AxiosError): boolean { /** 站内接口 401:清本地会话并回入口,与 {@link EntryGate} `session=expired` 衔接 */ /** 500 错误:更新全局服务器错误状态 */ lotteryHttp.interceptors.response.use( - (response) => response, + (response) => { + if (typeof window !== "undefined") { + useErrorStore.getState().setIsOffline(false); + } + return response; + }, (error: unknown) => { if (isAxiosError(error) && typeof window !== "undefined") { const status = error.response?.status;