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
+190 -40
View File
@@ -9,6 +9,10 @@ import {
priceStreamLifecycle,
priceWssEnabled,
} from '@/utils/priceSocketState'
import {
applyBoardSpecialToLastPayload,
setSpecialPricingPayload,
} from '@/utils/specialPricing'
export {
priceSocketConnected,
@@ -20,7 +24,8 @@ export {
const RECONNECT_BASE_MS = 3000
const RECONNECT_MAX_MS = 30000
const FAILURES_BEFORE_ALERT = 4
/** چند بار خطای پشت‌سرهم /api/price/last قبل از نمایش مدال */
const LAST_FETCH_FAILURES_BEFORE_ALERT = 2
const ALERT_COOLDOWN_MS = 5 * 60 * 1000
const FALLBACK_POLL_INTERVAL_MS = 10000
/** بدون قیمت معتبر از مرجع (SSE یا API) */
@@ -29,8 +34,95 @@ const PRICE_SOURCE_STALE_MS_DESKTOP = 35000
/** بدون event قیمت؛ ping هر ۵ث از سرور activity را تازه می‌کند */
const STALE_STREAM_MS = 120000
const FALLBACK_POLL_DELAY_MS = 2500
const STREAM_CONNECTING = 0
const STREAM_OPEN = 1
const STREAM_CLOSED = 2
let eventSource: EventSource | null = null
/** EventSource نمی‌تواند Authorization بفرستد؛ استریم همچنان auth:customer می‌خواهد. */
class AuthPriceStream extends EventTarget {
readyState = STREAM_CONNECTING
onopen: (() => void) | null = null
onerror: (() => void) | null = null
onmessage: (() => void) | null = null
private abort = new AbortController()
constructor(url: string, token: string) {
super()
void this.start(url, token)
}
close() {
this.readyState = STREAM_CLOSED
this.abort.abort()
}
private async start(url: string, token: string) {
try {
const response = await fetch(url, {
headers: {
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
signal: this.abort.signal,
})
if (!response.ok || !response.body) {
this.readyState = STREAM_CLOSED
this.onerror?.()
return
}
this.readyState = STREAM_OPEN
this.onopen?.()
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushSse(buffer)
}
this.readyState = STREAM_CLOSED
this.onerror?.()
} catch {
if (this.abort.signal.aborted) {
return
}
this.readyState = STREAM_CLOSED
this.onerror?.()
}
}
private flushSse(buffer: string): string {
const blocks = buffer.split('\n\n')
const rest = blocks.pop() ?? ''
for (const block of blocks) {
let event = 'message'
const data: string[] = []
for (const line of block.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
}
this.dispatchEvent(new MessageEvent(event, { data: data.join('\n') }))
this.onmessage?.()
}
return rest
}
}
let eventSource: AuthPriceStream | null = null
let subscribers = 0
let stopped = false
let getSetting: ((key: string) => unknown) | null = null
@@ -99,16 +191,12 @@ function resolveStreamUrl(): string | null {
return null
}
const token = findStoredAuthToken()
if (!token) {
if (!findStoredAuthToken()) {
return null
}
const base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '')
const url = new URL(`${base}${resolveStreamPath()}`)
url.searchParams.set('token', token)
return url.toString()
return `${base}${resolveStreamPath()}`
}
function clearReconnectTimer() {
@@ -137,7 +225,7 @@ function startStaleCheck() {
stopped
|| subscribers <= 0
|| !eventSource
|| eventSource.readyState !== EventSource.OPEN
|| eventSource.readyState !== STREAM_OPEN
) {
return
}
@@ -190,6 +278,16 @@ function scheduleFallbackPoll() {
}, FALLBACK_POLL_DELAY_MS)
}
export function clearPriceNetworkAlert() {
networkAlertOpen = false
pendingPriceAlert = false
}
/** وقتی /api/price/last خطا می‌دهد (از polling عادی یا fallback) */
export function notifyPriceLastFetchFailed() {
void maybeShowNetworkAlert()
}
async function runFallbackPoll() {
if (!priceWssEnabled.value || priceSocketConnected.value) {
return
@@ -213,8 +311,9 @@ async function runFallbackPoll() {
&& priceResult.value === true
&& hasRecentPriceFromSource(getPriceSourceStaleMs())
if (!priceOk) {
markPriceSourceFetchFailed()
if (priceOk) {
clearPriceNetworkAlert()
return
}
void maybeShowNetworkAlert()
@@ -243,25 +342,7 @@ async function isRealPriceOutage(): Promise<boolean> {
return false
}
const fetchFailures = consecutivePriceSourceFailures()
if (fetchFailures >= 2) {
return true
}
if (!priceSocketConnected.value && fetchFailures >= 1 && reconnectAttempt >= 2) {
return true
}
if (!isMobileDevice() && !navigator.onLine && fetchFailures >= 1) {
return true
}
if (!isMobileDevice() && reconnectAttempt >= FAILURES_BEFORE_ALERT) {
return true
}
return false
return consecutivePriceSourceFailures() >= LAST_FETCH_FAILURES_BEFORE_ALERT
}
function showNetworkAlert() {
@@ -322,7 +403,6 @@ function scheduleReconnect() {
reconnectTimer = setTimeout(() => {
reconnectAttempt += 1
connect()
maybeShowNetworkAlert()
}, delay)
}
@@ -331,13 +411,61 @@ async function applyLastFromStream(last: Price) {
applyLastPricePayload(last)
}
async function applySettingsFromStream(payload: unknown) {
const items = Array.isArray(payload)
? payload
: payload && typeof payload === 'object' && Array.isArray((payload as { items?: unknown }).items)
? (payload as { items: unknown[] }).items
: null
if (!items?.length) {
return
}
try {
const { useSettingsStore } = await import('@/store/settingsStore')
const { touchStoreHoursSettings } = await import('@/utils/storeHours')
useSettingsStore().mergeSettings(items as Array<{ key?: string; value?: unknown }>)
touchStoreHoursSettings()
} catch (error) {
console.warn('Settings SSE apply failed', error)
}
}
function handleLastPayload(payload: unknown) {
if (!payload || typeof payload !== 'object') {
return
}
const last = payload as Price
if (last.price_last == null && last.price == null && last.buy == null) {
const raw = payload as Price & { settings_patch?: unknown }
const last = applyBoardSpecialToLastPayload(raw)
const hasDisplayable =
Number(last.buy ?? 0) > 0
|| Number(last.sell ?? 0) > 0
|| Number(last.price_last ?? 0) > 0
|| Number(last.price ?? 0) > 0
const hasStatus = last.status != null || typeof last.closed === 'boolean'
if (!hasDisplayable && !hasStatus) {
if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) {
touchStreamActivity()
void applySettingsFromStream(raw.settings_patch)
}
return
}
// SSE گاهی last خالی/no_quote می‌فرستد؛ قیمت معتبر فعلی را پاک نکن
if (!hasDisplayable) {
if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) {
touchStreamActivity()
void applySettingsFromStream(raw.settings_patch)
}
void import('@/utils/price').then(({ getTradeBuyPrice, lastPrice }) => {
const keepCurrent =
getTradeBuyPrice(lastPrice.value) > 0 && last.status !== 'closed'
touchStreamActivity()
if (keepCurrent) return
void applyLastFromStream(last)
})
return
}
@@ -345,6 +473,11 @@ function handleLastPayload(payload: unknown) {
void applyLastFromStream(last)
}
function handleSettingsPayload(payload: unknown) {
touchStreamActivity()
void applySettingsFromStream(payload)
}
async function applyListFromStream(products: Product[]) {
const { applyProductsListFromSocket } = await import(
'@/views/base/products/composables/useProductsPageData'
@@ -411,7 +544,7 @@ async function applyUnreadCountFromStreamPayload(payload: Record<string, unknown
}
}
function bindStreamEvents(source: EventSource) {
function bindStreamEvents(source: AuthPriceStream) {
source.addEventListener('last', (event) => {
try {
handleLastPayload(JSON.parse(event.data))
@@ -420,6 +553,14 @@ function bindStreamEvents(source: EventSource) {
}
})
source.addEventListener('settings', (event) => {
try {
handleSettingsPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Price SSE settings parse failed', error)
}
})
source.addEventListener('list', (event) => {
try {
handleListPayload(JSON.parse(event.data))
@@ -452,6 +593,14 @@ function bindStreamEvents(source: EventSource) {
}
})
source.addEventListener('special-pricing', (event) => {
try {
setSpecialPricingPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Special-pricing SSE parse failed', error)
}
})
source.addEventListener('ping', () => {
touchStreamActivity()
})
@@ -493,7 +642,8 @@ function connect() {
}
const url = resolveStreamUrl()
if (!url) {
const token = findStoredAuthToken()
if (!url || !token) {
priceSocketConnected.value = false
priceStreamLifecycle.value = 'closed'
clearConnectingLifecycle()
@@ -502,7 +652,7 @@ function connect() {
if (eventSource) {
const state = eventSource.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
return
}
disconnectSourceOnly()
@@ -511,7 +661,7 @@ function connect() {
markConnectingLifecycle()
try {
eventSource = new EventSource(url)
eventSource = new AuthPriceStream(url, token)
} catch (error) {
console.warn('Price SSE connect failed', error)
handleStreamLost()
@@ -534,7 +684,7 @@ function connect() {
eventSource.onerror = () => {
const state = eventSource?.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
return
}
@@ -648,7 +798,7 @@ function onVisibilityChange() {
void maybeShowNetworkAlert()
}
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
if (!eventSource || eventSource.readyState !== STREAM_OPEN) {
forceReconnect()
}
}
@@ -658,7 +808,7 @@ function onPageShow(event: PageTransitionEvent) {
return
}
if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) {
if (event.persisted || !eventSource || eventSource.readyState !== STREAM_OPEN) {
reconnectAttempt = 0
networkAlertOpen = false
forceReconnect()