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:
2026-09-02 08:26:52 -04:00
co-authored by Cursor
parent e9012e26fb
commit 9356a2ab5e
112 changed files with 6044 additions and 583 deletions
+37
View File
@@ -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
View File
@@ -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)