new UI and handle cashing

This commit is contained in:
2026-06-16 19:47:28 +03:30
parent e174ae28c5
commit 6e5ea0319f
41 changed files with 2936 additions and 641 deletions
+37
View File
@@ -0,0 +1,37 @@
import type { AppConfig } from './types'
type RawAppConfig = AppConfig & {
user?: Record<string, any>
}
let appConfig: RawAppConfig | null = null
export function setAppConfig(config: RawAppConfig) {
appConfig = config || {}
window.__APP_CONFIG__ = appConfig
}
export function getRawAppConfig() {
return appConfig ?? window.__APP_CONFIG__ ?? null
}
export function getTenantConfig() {
const rawConfig = getRawAppConfig()
if (rawConfig?.user && typeof rawConfig.user === 'object') {
return rawConfig.user
}
if (rawConfig && Object.keys(rawConfig).length > 0) {
return rawConfig
}
return {}
}
export function getConfig<T = any>(key: string, fallback?: T): T {
const config = getTenantConfig()
const value = config?.[key]
return (value ?? fallback ?? null) as T
}
+83
View File
@@ -0,0 +1,83 @@
import { getConfig } from '@/config/app-config'
import { isPwaManifestReady } from '@/modules/pwa/manifest'
export function applyAppConfig() {
if (isPwaManifestReady()) return
setTitle()
setFavicon()
setAppleTouchIconFallback()
}
function setTitle() {
document.title = getConfig('name_fa', 'در حال بارگذاری...')
}
function getLogoUrl() {
return getConfig<string>('avatar') || '/favicon.png'
}
function setFavicon() {
let faviconLink =
document.querySelector<HTMLLinkElement>("link[rel='icon']") ||
document.querySelector<HTMLLinkElement>("link[rel~='icon']")
if (!faviconLink) {
faviconLink = document.createElement('link')
faviconLink.rel = 'icon'
document.head.appendChild(faviconLink)
}
const logoUrl = getLogoUrl()
faviconLink.type = 'image/png'
faviconLink.href = logoUrl.startsWith('http') ? logoUrl : resolveRemoteLogo(logoUrl)
}
function setAppleTouchIconFallback() {
const logoUrl = getLogoUrl()
if (!logoUrl) return
if (document.querySelector("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")) {
return
}
const sizes = ['180x180', '152x152', '120x120']
sizes.forEach((size) => {
let link = document.querySelector<HTMLLinkElement>(
`link[rel='apple-touch-icon'][sizes='${size}']`
)
if (!link) {
link = document.createElement('link')
link.rel = 'apple-touch-icon'
link.sizes = size
document.head.appendChild(link)
}
link.href = logoUrl.startsWith('http') ? logoUrl : resolveRemoteLogo(logoUrl)
})
let appleTitle = document.querySelector<HTMLMetaElement>("meta[name='apple-mobile-web-app-title']")
if (!appleTitle) {
appleTitle = document.createElement('meta')
appleTitle.name = 'apple-mobile-web-app-title'
document.head.appendChild(appleTitle)
}
appleTitle.content = getConfig('name_fa', 'اپ مشتری')
}
function resolveRemoteLogo(path: string) {
if (/^(https?:\/\/|data:|blob:)/i.test(path)) return path
const lastSuccess = localStorage.getItem('CONFIG_Success_URL')
const base = lastSuccess
? lastSuccess.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
: 'https://nerkh.gold'
return `${base}/${path.replace(/^\/+/, '')}`
}
+278
View File
@@ -0,0 +1,278 @@
import type { AppConfig } from '@/config/types'
import { resetVersionCheckCache } from '@/config/load-version'
import { clearCachedPwaManifest } from '@/modules/pwa/manifest-cache'
import { getTokenName } from '@/themeConfig'
const CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
const CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
const MAX_COOKIE_VALUE_LENGTH = 3500
const VERSION_STORAGE_KEY = 'version'
const VERSION_UPDATE_LOCK_KEY = 'version_update_lock'
const VERSION_LAST_RELOAD_KEY = 'version_last_reload'
type CachedConfigPayload = {
cached_at: number
config: AppConfig
}
type VersionUpdateLock = {
version: string
started_at: number
}
function getCookie(name: string) {
const cookie = document.cookie.split('; ').find((row) => row.startsWith(`${name}=`))
if (!cookie) return null
return cookie.substring(name.length + 1)
}
function setCookie(name: string, value: string, maxAgeSeconds: number) {
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie = [
`${name}=${value}`,
'Path=/',
`Max-Age=${maxAgeSeconds}`,
'SameSite=Lax',
secure
].join('; ')
}
export function readCachedAppConfig(): AppConfig | null {
try {
const rawCookie = getCookie(CONFIG_COOKIE_NAME)
if (!rawCookie) return null
const decoded = decodeURIComponent(rawCookie)
const payload = JSON.parse(decoded) as CachedConfigPayload
if (!payload?.config) return null
return payload.config
} catch (error) {
console.warn('Failed to read cached app config:', error)
clearCachedAppConfig()
return null
}
}
export function writeCachedAppConfig(config: AppConfig) {
try {
const payload: CachedConfigPayload = {
cached_at: Date.now(),
config
}
const encoded = encodeURIComponent(JSON.stringify(payload))
if (encoded.length > MAX_COOKIE_VALUE_LENGTH) {
console.warn(`App config is too large for cookie cache. Size: ${encoded.length}`)
return
}
setCookie(CONFIG_COOKIE_NAME, encoded, CONFIG_COOKIE_MAX_AGE_SECONDS)
} catch (error) {
console.warn('Failed to write app config cache:', error)
}
}
export function clearCachedAppConfig() {
document.cookie = `${CONFIG_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax`
clearCachedPwaManifest()
}
export function getStoredAppVersion() {
return localStorage.getItem(VERSION_STORAGE_KEY)
}
export function writeStoredAppVersion(version: string) {
localStorage.setItem(VERSION_STORAGE_KEY, String(version).trim())
}
export function getNormalizedVersion(version: unknown) {
if (version === null || version === undefined) return ''
return String(version).trim()
}
export function shouldUpdateForVersion(serverVersion: unknown) {
const normalizedServerVersion = getNormalizedVersion(serverVersion)
if (!normalizedServerVersion) return false
const storedVersion = getStoredAppVersion()
if (!storedVersion) {
writeStoredAppVersion(normalizedServerVersion)
return false
}
return storedVersion !== normalizedServerVersion
}
export function canStartVersionUpdate(serverVersion: string) {
const version = getNormalizedVersion(serverVersion)
if (!version) return false
const lastReloadVersion = sessionStorage.getItem(VERSION_LAST_RELOAD_KEY)
if (lastReloadVersion === version) {
return false
}
const rawLock = sessionStorage.getItem(VERSION_UPDATE_LOCK_KEY)
if (rawLock) {
try {
const lock = JSON.parse(rawLock) as VersionUpdateLock
const isSameVersion = lock.version === version
const isFreshLock = Date.now() - lock.started_at < 60_000
if (isSameVersion && isFreshLock) {
return false
}
} catch {
sessionStorage.removeItem(VERSION_UPDATE_LOCK_KEY)
}
}
return true
}
export function startVersionUpdateLock(serverVersion: string) {
const version = getNormalizedVersion(serverVersion)
const lock: VersionUpdateLock = {
version,
started_at: Date.now()
}
sessionStorage.setItem(VERSION_UPDATE_LOCK_KEY, JSON.stringify(lock))
}
export function finishVersionUpdateBeforeReload(serverVersion: string) {
const version = getNormalizedVersion(serverVersion)
writeStoredAppVersion(version)
sessionStorage.setItem(VERSION_LAST_RELOAD_KEY, version)
sessionStorage.removeItem(VERSION_UPDATE_LOCK_KEY)
}
export function cancelVersionUpdateLock() {
sessionStorage.removeItem(VERSION_UPDATE_LOCK_KEY)
}
function clearReadableCookiesExceptConfigAlreadyHandled() {
document.cookie.split(';').forEach((cookie) => {
const name = cookie.split('=')[0]?.trim()
if (!name) return
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`
})
}
export async function clearBrowserRuntimeCaches() {
try {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map((registration) => registration.unregister()))
}
} catch (error) {
console.warn('Could not unregister service workers:', error)
}
try {
if (!('caches' in window)) return
const cacheKeys = await caches.keys()
await Promise.all(cacheKeys.map((key) => caches.delete(key)))
} catch (error) {
console.warn('Could not clear browser caches:', error)
}
}
function clearLocalStorageExceptToken() {
const tokenKey = getTokenName()
const token = localStorage.getItem(tokenKey)
localStorage.clear()
if (token) {
localStorage.setItem(tokenKey, token)
}
}
export function clearAppStorageExceptToken() {
clearReadableCookiesExceptConfigAlreadyHandled()
clearLocalStorageExceptToken()
sessionStorage.clear()
}
export function clearAllBootstrapFetchCaches() {
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
clearCachedPwaManifest()
resetVersionCheckCache()
window.__APP_CONFIG__ = undefined
}
export function hardReloadApp() {
const refreshUrl = `${window.location.origin}${window.location.pathname}?__app_refresh=${Date.now()}`
window.location.href = refreshUrl
}
export async function refreshConfigCacheForVersion(
newVersion: string,
fetchConfig: () => Promise<AppConfig>
) {
const normalizedVersion = getNormalizedVersion(newVersion)
if (!normalizedVersion) {
throw new Error('Version is empty')
}
startVersionUpdateLock(normalizedVersion)
clearCachedAppConfig()
clearAppStorageExceptToken()
clearAllBootstrapFetchCaches()
await clearBrowserRuntimeCaches()
const freshConfig = await fetchConfig()
writeCachedAppConfig(freshConfig)
finishVersionUpdateBeforeReload(normalizedVersion)
return freshConfig
}
export async function resetAppAndRefreshConfig(
fetchConfig: () => Promise<AppConfig>,
version?: string | null
) {
const normalizedVersion = getNormalizedVersion(version)
clearCachedAppConfig()
clearAppStorageExceptToken()
clearAllBootstrapFetchCaches()
await clearBrowserRuntimeCaches()
const freshConfig = await fetchConfig()
writeCachedAppConfig(freshConfig)
if (normalizedVersion) {
writeStoredAppVersion(normalizedVersion)
}
return freshConfig
}
+92
View File
@@ -0,0 +1,92 @@
import type { AppConfig } from '@/config/types'
import { readCachedAppConfig, writeCachedAppConfig } from '@/config/config-cache'
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
function getProtocol() {
return window.location.protocol === 'https:' ? 'https:' : 'http:'
}
function getConfigUrls() {
const protocol = getProtocol()
return [
`${protocol}//nerkh.gold/get_user`,
`${protocol}//tablo.talasevin.ir/get_user`,
`${protocol}//abshode-service.montgit.ir/server/get_user`,
`${protocol}//212.23.201.173:8381/get_user`,
`${protocol}//91.212.174.137:4040/server/get_user`
]
}
function sortConfigUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
return [...urls].sort((a, b) => {
if (a === lastSuccessUrl) return -1
if (b === lastSuccessUrl) return 1
return 0
})
}
async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
},
cache: 'no-store',
signal: controller.signal
})
if (!response.ok) {
throw new Error(`Config request failed with status ${response.status}`)
}
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
throw new Error('Config response is not JSON')
}
return await response.json()
} finally {
clearTimeout(timeoutId)
}
}
export async function fetchFreshAppConfig(): Promise<AppConfig> {
const urls = sortConfigUrls(getConfigUrls())
for (let index = 0; index < urls.length; index++) {
const url = urls[index]
try {
const timeout = index === urls.length - 1 ? 5000 : 2500
const config = await fetchConfig(url, timeout)
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
writeCachedAppConfig(config)
return config
} catch (error) {
console.warn(`Config failed from ${url}`, error)
}
}
throw new Error('Cannot load app config from any server')
}
export async function loadAppConfig(): Promise<AppConfig> {
const cachedConfig = readCachedAppConfig()
if (cachedConfig) {
return cachedConfig
}
return await fetchFreshAppConfig()
}
+167
View File
@@ -0,0 +1,167 @@
type VersionResponse =
| string
| number
| {
version?: string | number
data?: string | number | { version?: string | number; value?: string | number }
value?: string | number
user?: unknown
}
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
const VERSION_RETRY_ROUNDS = 5
let versionFetchPromise: Promise<string | null> | null = null
let cachedVersionResult: string | null | undefined
function getVersionUrls() {
return [
'https://nerkh.gold/get_version',
'https://tablo.talasevin.ir/get_version',
'https://abshode-service.montgit.ir/server/get_version'
]
}
function sortVersionUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
if (!lastSuccessUrl) {
return urls
}
const others = urls.filter((url) => url !== lastSuccessUrl)
return [lastSuccessUrl, ...others]
}
function readUserVersion(user: unknown) {
if (!user || typeof user !== 'object' || Array.isArray(user)) {
return ''
}
const record = user as { version?: string | number; value?: string | number }
const version = record.version ?? record.value
return version === undefined || version === null ? '' : String(version).trim()
}
function normalizeVersionResponse(response: VersionResponse) {
if (typeof response === 'string' || typeof response === 'number') {
return String(response).trim()
}
if (!response) return ''
if (typeof response.data === 'string' || typeof response.data === 'number') {
return String(response.data).trim()
}
const directVersion =
response.version ??
response.value ??
(typeof response.data === 'object' ? response.data?.version ?? response.data?.value : undefined)
if (directVersion !== undefined && directVersion !== null) {
return String(directVersion).trim()
}
return readUserVersion(response.user)
}
async function fetchVersion(url: string, timeout = 3500): Promise<string | null> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
},
cache: 'no-store',
signal: controller.signal
})
if (!response.ok) {
// Non-2xx/timeout should be treated as "request failed" and we may try the next URL.
throw new Error(`Version request failed with status ${response.status}`)
}
const contentType = response.headers.get('content-type') ?? ''
const data = contentType.includes('application/json')
? ((await response.json()) as VersionResponse)
: ((await response.text()) as VersionResponse)
const version = normalizeVersionResponse(data)
// IMPORTANT: if the request returned 200/ok, we stop here even if we couldn't extract a version.
// This prevents unnecessary looping when the endpoint is "reachable" but returns an empty payload.
if (!version) {
return null
}
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
return version
} finally {
window.clearTimeout(timeoutId)
}
}
async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<string | null> {
const urls = sortVersionUrls(getVersionUrls())
for (let round = 1; round <= rounds; round++) {
for (let index = 0; index < urls.length; index++) {
const url = urls[index]
const timeout = index === urls.length - 1 ? 6000 : 3500
try {
const version = await fetchVersion(url, timeout)
// fetchVersion only throws on real request failures (non-ok / timeout).
// If it returns null, we got an HTTP 200 but empty body => stop immediately.
return version
} catch (error) {
console.warn(`Version failed from ${url}. Round: ${round}`, error)
}
}
}
return null
}
export function resetVersionCheckCache() {
cachedVersionResult = undefined
versionFetchPromise = null
}
export async function loadRemoteAppVersion(options?: {
rounds?: number
force?: boolean
}): Promise<string | null> {
const rounds = options?.rounds ?? VERSION_RETRY_ROUNDS
const force = options?.force ?? false
if (force) {
resetVersionCheckCache()
}
if (cachedVersionResult !== undefined) {
return cachedVersionResult
}
if (versionFetchPromise) {
return versionFetchPromise
}
versionFetchPromise = fetchVersionWithRetry(rounds)
.then((version) => {
cachedVersionResult = version
return version
})
.finally(() => {
versionFetchPromise = null
})
return versionFetchPromise
}
+20
View File
@@ -0,0 +1,20 @@
export interface AppConfig {
mode?: string
theme?: string
name?: string
name_fa?: string
avatar?: string
color?: string
background_color?: string
secondary_color?: string
node_server?: string
node_ws?: string
manifest_url?: string
user?: Record<string, any>
[key: string]: any
}