Port modular price, product, and PWA fixes into boursecu
Sync live quote handling, SSE product images, store hours, file URL normalization, PWA icon cache, TV monitor, about software, SuperAdmin menu gates, price alerts, and related UX from modular while preserving boursecu branding and config constraints. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,8 @@ export type PwaManifest = {
|
||||
|
||||
const MANIFEST_CACHE_KEY = 'PWA_MANIFEST_CACHE_V5'
|
||||
const MANIFEST_CACHE_MAX_AGE_MS = 60 * 60 * 24 * 7
|
||||
/** با اسکریپت inline در index.html هماهنگ بماند */
|
||||
export const PWA_INSTALL_ICON_URL_KEY = 'PWA_INSTALL_ICON_URL'
|
||||
|
||||
export type CachedPwaManifest = {
|
||||
cached_at: number
|
||||
@@ -69,4 +71,39 @@ export function writeCachedPwaManifest(entry: CachedPwaManifest) {
|
||||
|
||||
export function clearCachedPwaManifest() {
|
||||
localStorage.removeItem(MANIFEST_CACHE_KEY)
|
||||
try {
|
||||
localStorage.removeItem(PWA_INSTALL_ICON_URL_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedPwaInstallIconUrl(src: string) {
|
||||
try {
|
||||
const value = String(src || '').trim()
|
||||
if (!value) return
|
||||
localStorage.setItem(PWA_INSTALL_ICON_URL_KEY, value)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** برای گرمکردن آیکون نصب، بدون نیاز به userId / configBase */
|
||||
export function readAnyFreshCachedPwaManifest(): CachedPwaManifest | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(MANIFEST_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
|
||||
const payload = JSON.parse(raw) as CachedPwaManifest
|
||||
if (!payload?.manifest || !payload.manifest_url) return null
|
||||
if (!Array.isArray(payload.manifest.icons) || payload.manifest.icons.length === 0) return null
|
||||
|
||||
if (Date.now() - Number(payload.cached_at || 0) > MANIFEST_CACHE_MAX_AGE_MS) {
|
||||
return null
|
||||
}
|
||||
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
+135
-12
@@ -1,9 +1,16 @@
|
||||
import type { AppConfig } from '@/config/types'
|
||||
import { getConfig, getRawAppConfig } from '@/config/app-config'
|
||||
import { getConfigUrls, sortConfigUrls } from '@/config/load-config'
|
||||
import {
|
||||
getSuperAdminBase,
|
||||
isSuperAdminUrl,
|
||||
resolveTenantDomain,
|
||||
} from '@/config/superadmin'
|
||||
import {
|
||||
clearCachedPwaManifest,
|
||||
readAnyFreshCachedPwaManifest,
|
||||
readCachedPwaManifest,
|
||||
writeCachedPwaInstallIconUrl,
|
||||
writeCachedPwaManifest,
|
||||
type PwaManifest
|
||||
} from '@/modules/pwa/manifest-cache'
|
||||
@@ -22,8 +29,26 @@ function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** پایهٔ سرور از URLهای get_user یا manifest — query مثل ?domain= را حذف میکند */
|
||||
function normalizeBaseUrl(url: string) {
|
||||
return url.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
parsed.search = ''
|
||||
parsed.hash = ''
|
||||
parsed.pathname = parsed.pathname
|
||||
.replace(/\/get_user\/?$/i, '')
|
||||
.replace(/\/manifest\.json$/i, '')
|
||||
if (!parsed.pathname || parsed.pathname === '/') {
|
||||
return parsed.origin
|
||||
}
|
||||
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
|
||||
} catch {
|
||||
return url
|
||||
.replace(/[?#].*$/, '')
|
||||
.replace(/\/get_user\/?$/i, '')
|
||||
.replace(/\/manifest\.json$/i, '')
|
||||
.replace(/\/+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
/** همان baseای که get_user از آن لود شده (کش شده در localStorage) */
|
||||
@@ -33,15 +58,31 @@ export function getConfigBaseFromStorage() {
|
||||
return normalizeBaseUrl(lastSuccess)
|
||||
}
|
||||
|
||||
function configUrlToManifestUrl(configUrl: string) {
|
||||
return `${configUrl.replace(/\/+$/, '')}/manifest.json`
|
||||
function getSuperAdminManifestUrl(): string | null {
|
||||
const saBase = getSuperAdminBase()
|
||||
if (!saBase) return null
|
||||
return `${saBase}/manifest.json?domain=${encodeURIComponent(resolveTenantDomain())}`
|
||||
}
|
||||
|
||||
/** همان ۵ URL پایهٔ get_user — اولویت با CONFIG_Success_URL */
|
||||
function configUrlToManifestUrl(configUrl: string) {
|
||||
if (isSuperAdminUrl(configUrl)) {
|
||||
return getSuperAdminManifestUrl() || `${normalizeBaseUrl(configUrl)}/manifest.json`
|
||||
}
|
||||
return `${normalizeBaseUrl(configUrl)}/manifest.json`
|
||||
}
|
||||
|
||||
/**
|
||||
* اولویت: manifest_url صریح → سوپرادمین (با domain) → رجیستریهای قدیمی
|
||||
*/
|
||||
export function resolveManifestUrls(config?: AppConfig | null): string[] {
|
||||
const explicit = getConfig<string>('manifest_url')
|
||||
if (explicit) return [explicit]
|
||||
|
||||
const saManifest = getSuperAdminManifestUrl()
|
||||
if (saManifest) {
|
||||
return [saManifest]
|
||||
}
|
||||
|
||||
return sortConfigUrls(getConfigUrls()).map(configUrlToManifestUrl)
|
||||
}
|
||||
|
||||
@@ -222,8 +263,12 @@ function mapManifestIcons(manifest: PwaManifest, base: string) {
|
||||
}
|
||||
|
||||
/** فقط آیکونهای داخل monifist.json — بدون آواتار / pwa-icons / env */
|
||||
function resolveInstallIcons(manifest: PwaManifest) {
|
||||
const base = getConfigBaseFromStorage()
|
||||
function resolveInstallIcons(manifest: PwaManifest, manifestUrl?: string) {
|
||||
const lastSuccess = localStorage.getItem(CONFIG_SUCCESS_URL_KEY) || ''
|
||||
const fromManifest = manifestUrl ? normalizeBaseUrl(manifestUrl) : ''
|
||||
const fromConfig =
|
||||
lastSuccess && !isSuperAdminUrl(lastSuccess) ? getConfigBaseFromStorage() : ''
|
||||
const base = fromManifest || fromConfig
|
||||
const mapped = mapManifestIcons(manifest, base)
|
||||
|
||||
if (!mapped.length) {
|
||||
@@ -235,10 +280,10 @@ function resolveInstallIcons(manifest: PwaManifest) {
|
||||
|
||||
|
||||
|
||||
function prepareManifestForInstall(manifest: PwaManifest) {
|
||||
function prepareManifestForInstall(manifest: PwaManifest, manifestUrl?: string) {
|
||||
const scopeUrl = toSameOriginManifestUrl(manifest.scope ?? manifest.start_url, '/')
|
||||
const startUrl = toSameOriginManifestUrl(manifest.start_url, '/')
|
||||
const icons = resolveInstallIcons(manifest)
|
||||
const icons = resolveInstallIcons(manifest, manifestUrl)
|
||||
const name = manifest.short_name || manifest.name
|
||||
|
||||
if (!name) {
|
||||
@@ -288,6 +333,60 @@ function pickManifestIconUrl(manifest: PwaManifest, preferredSizes: number[]) {
|
||||
return sorted[0]?.src || ''
|
||||
}
|
||||
|
||||
const PWA_ICON_CACHE_NAME = 'pwa-install-icons-v1'
|
||||
|
||||
function uniqueIconSrcs(manifest: PwaManifest): string[] {
|
||||
const seen = new Set<string>()
|
||||
const urls: string[] = []
|
||||
for (const icon of manifest.icons || []) {
|
||||
const src = String(icon?.src || '').trim()
|
||||
if (!src || seen.has(src)) continue
|
||||
seen.add(src)
|
||||
urls.push(src)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
/** کش HTTP مرورگر — همان مسیری که شیت نصب iOS آیکون را میگیرد (نه blob) */
|
||||
function warmIconInHttpCache(src: string) {
|
||||
const img = new Image()
|
||||
img.decoding = 'async'
|
||||
img.src = src
|
||||
void img.decode().catch(() => undefined)
|
||||
}
|
||||
|
||||
async function persistIconInCacheApi(src: string) {
|
||||
if (typeof caches === 'undefined') return
|
||||
|
||||
try {
|
||||
const cache = await caches.open(PWA_ICON_CACHE_NAME)
|
||||
if (await cache.match(src)) return
|
||||
|
||||
const fetched = await fetch(src, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
cache: 'force-cache',
|
||||
priority: 'high',
|
||||
} as RequestInit)
|
||||
|
||||
if (!fetched.ok) return
|
||||
await cache.put(src, fetched)
|
||||
} catch {
|
||||
// بدون CORS یا سهمیه — Image() همچنان کش HTTP را گرم میکند
|
||||
}
|
||||
}
|
||||
|
||||
function warmPwaInstallIcons(manifest: PwaManifest) {
|
||||
const urls = uniqueIconSrcs(manifest)
|
||||
if (!urls.length) return
|
||||
|
||||
urls.forEach((src) => {
|
||||
warmIconInHttpCache(src)
|
||||
void persistIconInCacheApi(src)
|
||||
})
|
||||
}
|
||||
|
||||
function upsertLink(rel: string, attrs: Record<string, string>) {
|
||||
const selectorParts = Object.entries(attrs)
|
||||
.filter(([key]) => key !== 'href')
|
||||
@@ -351,6 +450,7 @@ function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
|
||||
|
||||
upsertLink('apple-touch-icon', {
|
||||
'data-dynamic-pwa': 'true',
|
||||
'data-pwa-icon-src': href,
|
||||
sizes: `${size}x${size}`,
|
||||
href
|
||||
})
|
||||
@@ -359,8 +459,10 @@ function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
|
||||
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
|
||||
|
||||
if (primary) {
|
||||
writeCachedPwaInstallIconUrl(primary)
|
||||
upsertLink('apple-touch-icon', {
|
||||
'data-dynamic-pwa': 'true',
|
||||
'data-pwa-icon-src': primary,
|
||||
href: primary
|
||||
})
|
||||
}
|
||||
@@ -410,12 +512,14 @@ export async function applyPreparedManifestToDocument(
|
||||
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
|
||||
|
||||
if (favicon) {
|
||||
upsertLink('icon', { type: 'image/png', href: favicon })
|
||||
upsertLink('icon', { type: 'image/png', href: favicon, 'data-pwa-icon-src': favicon })
|
||||
}
|
||||
|
||||
warmPwaInstallIcons(normalized)
|
||||
}
|
||||
|
||||
export async function applyManifestToDocument(manifest: PwaManifest, manifestUrl: string) {
|
||||
const prepared = prepareManifestForInstall(manifest)
|
||||
const prepared = prepareManifestForInstall(manifest, manifestUrl)
|
||||
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
||||
}
|
||||
|
||||
@@ -431,6 +535,25 @@ export function isPwaManifestReady() {
|
||||
return sessionStorage.getItem(PWA_MANIFEST_READY_KEY) === '1'
|
||||
}
|
||||
|
||||
/** کش مانیفست قبلی را همان اول بوت روی apple-touch-icon مینشاند و تصویر را گرم میکند */
|
||||
export function applyCachedPwaChromeEarly() {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
||||
|
||||
const cached = readAnyFreshCachedPwaManifest()
|
||||
if (!cached?.manifest) return
|
||||
|
||||
const apply = (manifest: PwaManifest) =>
|
||||
applyPreparedManifestToDocument(manifest, cached.manifest_url).catch((error) => {
|
||||
console.warn('Early PWA icon apply failed:', error)
|
||||
})
|
||||
|
||||
try {
|
||||
void apply(prepareManifestForInstall(cached.manifest, cached.manifest_url))
|
||||
} catch {
|
||||
void apply(cached.manifest)
|
||||
}
|
||||
}
|
||||
|
||||
/** هرچه زودتر در main.ts — قبل از bootstrap */
|
||||
export function initPwaInstallCapture() {
|
||||
if (installCaptureInitialized || typeof window === 'undefined') return
|
||||
@@ -476,7 +599,7 @@ async function applyCachedManifestEntry(
|
||||
if (!cached) return false
|
||||
|
||||
try {
|
||||
const prepared = prepareManifestForInstall(cached.manifest)
|
||||
const prepared = prepareManifestForInstall(cached.manifest, cached.manifest_url)
|
||||
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
|
||||
setPwaManifestReady(true)
|
||||
return true
|
||||
@@ -514,7 +637,7 @@ export async function setupPwaManifest(config?: AppConfig | null): Promise<boole
|
||||
try {
|
||||
const { manifest, manifestUrl } = await fetchManifestTryingUrls(manifestUrls)
|
||||
|
||||
const prepared = prepareManifestForInstall(manifest)
|
||||
const prepared = prepareManifestForInstall(manifest, manifestUrl)
|
||||
|
||||
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
||||
setPwaManifestReady(true)
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
cancelVersionUpdateLock,
|
||||
hardReloadApp
|
||||
} from '@/config/config-cache'
|
||||
import { assertServerReachableConfig, isServerUnreachableConfig, isServerUnavailableError } from '@/utils/serverUnavailable'
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const hasError = ref(false)
|
||||
@@ -86,6 +87,11 @@ async function startUpdate(version: string) {
|
||||
|
||||
const freshConfig = await refreshConfigCacheForVersion(version, fetchFreshAppConfig)
|
||||
|
||||
if (isServerUnreachableConfig(freshConfig)) {
|
||||
assertServerReachableConfig(freshConfig)
|
||||
return
|
||||
}
|
||||
|
||||
setAppConfig(freshConfig)
|
||||
|
||||
await wait(500)
|
||||
@@ -97,6 +103,11 @@ async function startUpdate(version: string) {
|
||||
} catch (error) {
|
||||
console.error('Version update failed:', error)
|
||||
|
||||
if (isServerUnavailableError(error)) {
|
||||
cancelVersionUpdateLock()
|
||||
return
|
||||
}
|
||||
|
||||
if (configBackup) {
|
||||
window.__APP_CONFIG__ = configBackup
|
||||
setAppConfig(configBackup)
|
||||
|
||||
Reference in New Issue
Block a user