Keep gold quotes live from last and apply SSE catalog list so cards refresh without a page reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 18:20:06 +03:30
co-authored by Cursor
parent c623a32bb5
commit 3b83eac099
4 changed files with 105 additions and 103 deletions
+27 -2
View File
@@ -467,13 +467,38 @@ function handleSettingsPayload(payload: unknown) {
void applySettingsFromStream(payload)
}
function coerceStreamProductList(payload: unknown): Product[] {
if (Array.isArray(payload)) {
return payload as Product[]
}
if (!payload || typeof payload !== 'object') {
return []
}
const root = payload as Record<string, unknown>
if (Array.isArray(root.data)) return root.data as Product[]
if (Array.isArray(root.products)) return root.products as Product[]
if (Array.isArray(root.items)) return root.items as Product[]
if (root.id != null && ('price' in root || 'title' in root || 'group_type' in root)) {
return [payload as Product]
}
const values = Object.values(root)
if (
values.length > 0 &&
values.every((item) => item && typeof item === 'object' && 'id' in (item as object))
) {
return values as Product[]
}
return []
}
function handleListPayload(payload: unknown) {
if (!Array.isArray(payload) || !payload.length) {
const products = coerceStreamProductList(payload)
if (!products.length) {
return
}
touchStreamActivity()
applyProductsListFromStream(payload as Product[])
applyProductsListFromStream(products)
}
function handleAppNotificationPayload(payload: unknown) {
+6 -32
View File
@@ -101,26 +101,13 @@ export function getProductBaseBuyPrice(product?: Product | null): number {
}
if (isMeltedProductUsingAppPrice(product) || isAppPriceProduct(product)) {
const apiPrice = Number(product.price ?? 0)
const useLast = productUsesLastPriceIfZero(product)
if (!useLast) {
const liveBuy = getTradeBuyPrice(lastPrice.value)
if (liveBuy > 0) {
return applyPriceOffsetPercent(liveBuy, product.price_offset_percent)
}
return 0
}
if (apiPrice > 0) {
return apiPrice
}
const liveBuy = getTradeBuyPrice(lastPrice.value)
if (liveBuy > 0) {
return applyPriceOffsetPercent(liveBuy, product.price_offset_percent)
}
if (productUsesLastPriceIfZero(product) && Number(product.price ?? 0) > 0) {
return Number(product.price ?? 0)
}
return 0
}
@@ -195,26 +182,13 @@ export function getProductBaseSellPrice(product?: Product | null): number {
}
if (isMeltedProductUsingAppPrice(product) || isAppPriceProduct(product)) {
const apiPrice = Number(product.price ?? 0)
const useLast = productUsesLastPriceIfZero(product)
if (!useLast) {
const liveSell = getTradeSellPrice(lastPrice.value)
if (liveSell > 0) {
return applyPriceOffsetPercent(liveSell, product.price_offset_percent)
}
return 0
}
if (apiPrice > 0) {
return apiPrice
}
const liveSell = getTradeSellPrice(lastPrice.value)
if (liveSell > 0) {
return applyPriceOffsetPercent(liveSell, product.price_offset_percent)
}
if (productUsesLastPriceIfZero(product) && Number(product.price ?? 0) > 0) {
return Number(product.price ?? 0)
}
return 0
}
+2 -3
View File
@@ -319,7 +319,6 @@ import {
getProductsCatalogRef,
} from '@/composables/useProductsCatalog'
import { subscribeProductListStream } from '@/utils/productStreamBridge'
import { usesQuantityTrade } from '@/utils/productCategories'
import { useSettingsStore } from '@/store/settingsStore'
import { storeToRefs } from 'pinia'
import {
@@ -456,8 +455,8 @@ const sellPagination = ref<TableEngineInput>({
const unitPrice = computed(() => {
const product = tradeProduct.value
if (usesQuantityTrade(product)) {
return isBuy.value ? getProductBuyPrice(product!) : getProductSellPrice(product!)
if (product) {
return isBuy.value ? getProductBuyPrice(product) : getProductSellPrice(product)
}
return isBuy.value ? getTradeBuyPrice(lastPrice.value) : getTradeSellPrice(lastPrice.value)
})
@@ -14,6 +14,7 @@ import { getLastStreamProductsList, subscribeProductListStream } from '@/utils/p
let initPromise: Promise<void> | null = null
let productListInterval: ReturnType<typeof setInterval> | undefined
let catalogStreamRefreshTimer: ReturnType<typeof setTimeout> | undefined
let providerMountCount = 0
let pricePollingMountCount = 0
let unsubscribeProductStream: (() => void) | undefined
@@ -72,6 +73,10 @@ function pruneEmptyTypeItems() {
productsTypeItems.value = productsTypeItems.value.filter((key) => present.has(key))
}
function productRowId(product: Product): number {
return Number(product.id)
}
function productFileId(product: Product): number {
return Number(product.file_id ?? (product.file as { id?: number } | undefined)?.id ?? 0)
}
@@ -85,43 +90,47 @@ function productHasDisplayImage(product: Product): boolean {
}
/** استریم گاهی file خالی با همان file_id می‌فرستد — عکس خوب HTTP را پاک نکن. */
function mergeLiveProduct(prev: Product, incoming: Product): Product {
const next: Product = {
...prev,
...incoming,
open_buy: 'open_buy' in incoming ? incoming.open_buy : prev.open_buy,
open_sell: 'open_sell' in incoming ? incoming.open_sell : prev.open_sell,
}
if (!productHasDisplayImage(incoming) && productHasDisplayImage(prev)) {
next.file = prev.file
next.file_id = prev.file_id ?? (prev.file as { id?: number } | undefined)?.id ?? incoming.file_id
next.img = prev.img
} else if (productFileId(prev) > productFileId(incoming)) {
next.file = prev.file
next.file_id = prev.file_id
next.img = prev.img
}
return next
}
function mergeLiveProductList(incoming: Product[], current?: Product[]): Product[] {
if (!current?.length) return incoming
const currentById = new Map(current.map((item) => [item.id, item]))
const currentById = new Map(current.map((item) => [productRowId(item), item]))
const incomingById = new Map(incoming.map((item) => [productRowId(item), item]))
const incomingIds = incoming.map(productRowId)
const isPatch =
incoming.length < current.length &&
incomingIds.every((id) => currentById.has(id))
if (isPatch) {
return current.map((item) => {
const updated = incomingById.get(productRowId(item))
return updated ? mergeLiveProduct(item, updated) : item
})
}
return incoming.map((item) => {
const prev = currentById.get(item.id)
if (!prev) return item
const prevId = productFileId(prev)
const nextId = productFileId(item)
const keepTradeFlags = {
open_buy: 'open_buy' in item ? item.open_buy : prev.open_buy,
open_sell: 'open_sell' in item ? item.open_sell : prev.open_sell,
}
if (!productHasDisplayImage(item) && productHasDisplayImage(prev)) {
return {
...item,
...keepTradeFlags,
file: prev.file,
file_id: prev.file_id ?? (prev.file as { id?: number } | undefined)?.id ?? item.file_id,
img: prev.img,
}
}
if (prevId > nextId) {
return {
...item,
...keepTradeFlags,
file: prev.file,
file_id: prev.file_id,
img: prev.img,
}
}
return { ...item, ...keepTradeFlags }
const prev = currentById.get(productRowId(item))
return prev ? mergeLiveProduct(prev, item) : item
})
}
@@ -141,16 +150,18 @@ function isListedProduct(product: Product): boolean {
return flagOn(product.active)
}
/** لیست SSE فروشگاه دیگر را روی این صفحه ننشان */
/** لیست SSE فروشگاه دیگر را روی این صفحه ننشان؛ پچ یک‌محصوله همین فروشگاه را بپذیر */
function isSameShopCatalog(incoming: Product[], current?: Product[]): boolean {
if (!current?.length) return true
const incomingIds = new Set(incoming.map((item) => item.id))
const currentIds = new Set(current.map((item) => item.id))
const incomingIds = incoming.map((item) => productRowId(item))
const currentIds = new Set(current.map((item) => productRowId(item)))
let overlap = 0
incomingIds.forEach((id) => {
if (currentIds.has(id)) overlap += 1
})
return overlap / Math.max(incomingIds.size, currentIds.size) >= 0.5
if (overlap === 0) return false
if (overlap === incomingIds.length) return true
return overlap / Math.max(incomingIds.length, currentIds.size) >= 0.5
}
function applyProductsList(products: Product[]) {
@@ -171,10 +182,20 @@ function applyProductsList(products: Product[]) {
productsPageLoading.value = false
}
/** قیمت enrich شده فقط در HTTP کامل است؛ slim SSE برای پرچم‌هاست */
function scheduleCatalogRefreshFromStream() {
if (catalogStreamRefreshTimer) {
clearTimeout(catalogStreamRefreshTimer)
}
catalogStreamRefreshTimer = setTimeout(() => {
catalogStreamRefreshTimer = undefined
void refreshProductsPageData()
}, 150)
}
/**
* SSE همیشه لیست کامل محصولات فعال را می‌فرستد.
* عضو جدید/حذف‌شده را از همان لیست بگیر؛ فقط عکس را از نسخهٔ قبلی نگه دار.
* آپدیت قیمت روی همان آبجکت‌ها تا گرید نپرد.
* SSE لیست کامل یا پچ می‌فرستد.
* slim فوری برای open_buy/show؛ قیمت کارت مثل رفرش از /customer/product/list.
*/
function applySocketProductList(incoming: Product[], current?: Product[]) {
if (!isSameShopCatalog(incoming, current)) {
@@ -184,35 +205,13 @@ function applySocketProductList(incoming: Product[], current?: Product[]) {
const next = sortProductsByInventory(
mergeLiveProductList(incoming, current).filter(isListedProduct),
)
if (!current?.length) {
if (next.length) {
applyProductsList(next)
return
} else {
productsPageLoading.value = false
}
const nextById = new Map(next.map((item) => [item.id, item]))
const currentIds = new Set(current.map((item) => item.id))
const nextIds = new Set(next.map((item) => item.id))
const membershipChanged =
next.length !== current.length ||
next.some((item) => !currentIds.has(item.id)) ||
current.some((item) => !nextIds.has(item.id))
if (membershipChanged) {
applyProductsList(next)
return
}
current.forEach((item) => {
const updated = nextById.get(item.id)
if (updated) {
Object.assign(item, updated)
}
})
getProductsCatalogRef().value = current
changeProductsTab()
productsPageLoading.value = false
scheduleCatalogRefreshFromStream()
}
export async function refreshProductsPageData() {
@@ -305,6 +304,11 @@ export function onProductsPageProviderUnmounted() {
if (providerMountCount === 0) {
stopProductListPolling()
initialized.value = false
if (catalogStreamRefreshTimer) {
clearTimeout(catalogStreamRefreshTimer)
catalogStreamRefreshTimer = undefined
}
}
}