Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
import { texts } from '@/texts'
|
|
|
|
interface AsyncResourceOptions<T> {
|
|
cacheKey?: string
|
|
enabled?: boolean
|
|
initialData?: T | null
|
|
retries?: number
|
|
retryDelayMs?: number
|
|
staleTimeMs?: number
|
|
}
|
|
|
|
interface CacheEntry {
|
|
data: unknown
|
|
updatedAt: number
|
|
}
|
|
const resourceCache = new Map<string, CacheEntry>()
|
|
|
|
export const invalidateAsyncResource = (key: string) => {
|
|
resourceCache.delete(key)
|
|
}
|
|
|
|
/** Drop every module-level entry — call on logout so a later session cannot reuse prior data. */
|
|
export const clearAsyncResourceCache = () => {
|
|
resourceCache.clear()
|
|
}
|
|
|
|
export const useAsyncResource = <T>(
|
|
load: (signal: AbortSignal) => Promise<T>,
|
|
{ cacheKey, enabled = true, initialData = null, retries = 1, retryDelayMs = 300, staleTimeMs = 30_000 }: AsyncResourceOptions<T> = {}
|
|
) => {
|
|
const cached = cacheKey ? resourceCache.get(cacheKey) : undefined
|
|
const [data, setData] = useState<T | null>((cached?.data as T | undefined) ?? initialData)
|
|
const [error, setError] = useState<Error | null>(null)
|
|
const [isLoading, setIsLoading] = useState(enabled)
|
|
const requestIdRef = useRef(0)
|
|
const controllerRef = useRef<AbortController | null>(null)
|
|
|
|
useEffect(() => {
|
|
const nextCached = cacheKey ? resourceCache.get(cacheKey) : undefined
|
|
|
|
setData((nextCached?.data as T | undefined) ?? initialData)
|
|
}, [cacheKey, initialData])
|
|
|
|
const execute = useCallback(
|
|
async (ignoreCache = false) => {
|
|
if (!enabled) return null
|
|
|
|
const currentCache = cacheKey ? resourceCache.get(cacheKey) : undefined
|
|
|
|
if (!ignoreCache && currentCache && Date.now() - currentCache.updatedAt < staleTimeMs) {
|
|
const cachedData = currentCache.data as T
|
|
|
|
setData(cachedData)
|
|
setError(null)
|
|
setIsLoading(false)
|
|
|
|
return cachedData
|
|
}
|
|
|
|
controllerRef.current?.abort()
|
|
const controller = new AbortController()
|
|
const requestId = ++requestIdRef.current
|
|
|
|
controllerRef.current = controller
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
try {
|
|
const nextData = await load(controller.signal)
|
|
|
|
if (!controller.signal.aborted && requestId === requestIdRef.current) {
|
|
setData(nextData)
|
|
setIsLoading(false)
|
|
if (cacheKey) resourceCache.set(cacheKey, { data: nextData, updatedAt: Date.now() })
|
|
}
|
|
|
|
return nextData
|
|
} catch (cause) {
|
|
if (controller.signal.aborted) return null
|
|
if (attempt < retries) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, retryDelayMs * (attempt + 1)))
|
|
continue
|
|
}
|
|
const nextError = cause instanceof Error ? cause : new Error(texts.common.unexpectedError)
|
|
|
|
if (requestId === requestIdRef.current) setError(nextError)
|
|
}
|
|
}
|
|
|
|
if (requestId === requestIdRef.current) setIsLoading(false)
|
|
|
|
return null
|
|
},
|
|
[cacheKey, enabled, retries, retryDelayMs, staleTimeMs, load]
|
|
)
|
|
|
|
useEffect(() => {
|
|
void execute(false)
|
|
|
|
return () => controllerRef.current?.abort()
|
|
}, [execute])
|
|
|
|
const refetch = useCallback(() => execute(true), [execute])
|
|
|
|
const updateData = useCallback(
|
|
(nextData: T | null) => {
|
|
setData(nextData)
|
|
if (cacheKey) {
|
|
if (nextData === null) resourceCache.delete(cacheKey)
|
|
else resourceCache.set(cacheKey, { data: nextData, updatedAt: Date.now() })
|
|
}
|
|
},
|
|
[cacheKey]
|
|
)
|
|
|
|
return { data, error, isLoading, refetch, setData: updateData }
|
|
}
|