2026-08-20 10:06:55 +03:30
|
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
|
import { useFullscreen } from '@vueuse/core'
|
|
|
|
|
import type { Product } from '@/models/products'
|
|
|
|
|
import { getTvProducts } from '@/api/products'
|
|
|
|
|
import { getConfigApp } from '@/utils/config'
|
2026-09-06 21:35:54 +03:30
|
|
|
import { getTvProductBuyPrice, getTvProductSellPrice, isTvProductPriceInactive } from '@/utils/productPrice'
|
2026-08-20 10:06:55 +03:30
|
|
|
import { getLastPrices, getTradeBuyPrice, getTradeSellPrice, lastPrice, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
|
|
|
|
|
import { getPriceDisplayMessage, getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
|
2026-09-10 15:10:10 +03:30
|
|
|
import { isMeltedTradeGroup, isMetalCommodityGroup } from '@/utils/productCategories'
|
2026-08-20 10:06:55 +03:30
|
|
|
import { priceSocketConnected, priceStreamLifecycle } from '@/utils/priceSocketState'
|
|
|
|
|
import { usePrice } from '@/utils/usePrice'
|
|
|
|
|
import logoFallback from '@/assets/img/logo_full_.png'
|
|
|
|
|
//@ts-ignore
|
|
|
|
|
import moment from 'moment-jalaali'
|
|
|
|
|
//@ts-ignore
|
|
|
|
|
import fa from 'moment/src/locale/fa'
|
|
|
|
|
|
|
|
|
|
moment.locale('fa', fa)
|
|
|
|
|
moment.loadPersian({ usePersianDigits: false })
|
|
|
|
|
|
|
|
|
|
const CURSOR_HIDE_MS = 2500
|
|
|
|
|
const TV_HTML_CLASS = 'app-layout--tv'
|
|
|
|
|
const REFRESH_MS = 15000
|
|
|
|
|
|
|
|
|
|
function productText(product: Product) {
|
|
|
|
|
return `${product.group_type ?? ''} ${product.title ?? ''}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isShowOnTv(product: Product) {
|
|
|
|
|
const value = product.show_on_tv as unknown
|
2026-08-22 21:50:39 +03:30
|
|
|
return value === true || value === 1 || value === '1' || value === 'true'
|
2026-08-20 10:06:55 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isParsianCoin(product: Product) {
|
|
|
|
|
return productText(product).includes('پارسیان')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isBankCoin(product: Product) {
|
|
|
|
|
if (isParsianCoin(product)) return false
|
|
|
|
|
const group = (product.group_type ?? '').trim()
|
2026-09-10 19:46:35 +03:30
|
|
|
const title = (product.title ?? '').trim()
|
|
|
|
|
if (group === 'سکه' || group === 'سکه بانکی') return true
|
|
|
|
|
if (group.includes('سکه') && !group.includes('پارسیان')) return true
|
|
|
|
|
return title.includes('بانکی')
|
2026-08-20 10:06:55 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bankCoinRank(product: Product) {
|
|
|
|
|
const text = productText(product)
|
|
|
|
|
if (text.includes('نیم')) return 2
|
|
|
|
|
if (text.includes('ربع')) return 3
|
|
|
|
|
if (text.includes('تمام') || text.includes('امامی') || text.includes('بهار')) return 1
|
|
|
|
|
return 99
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSimorghBar(product: Product) {
|
|
|
|
|
return productText(product).includes('سیمرغ')
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 15:10:10 +03:30
|
|
|
function isGoldBar(product: Product) {
|
|
|
|
|
if (isMeltedTradeGroup(product.group_type) || isMetalCommodityGroup(product.group_type)) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const group = (product.group_type ?? '').trim()
|
|
|
|
|
if (group.includes('شمش') || isSimorghBar(product)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (product.title ?? '').includes('شمش')
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 10:06:55 +03:30
|
|
|
export function useTvMonitorPage() {
|
|
|
|
|
const rootEl = ref<HTMLElement | null>(null)
|
2026-08-22 22:35:10 +03:30
|
|
|
const { isFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(rootEl)
|
2026-08-20 10:06:55 +03:30
|
|
|
|
|
|
|
|
const products = ref<Product[]>([])
|
|
|
|
|
const loading = ref(true)
|
|
|
|
|
const nowLabel = ref('')
|
|
|
|
|
const dateLabel = ref('')
|
|
|
|
|
const cursorHidden = ref(false)
|
|
|
|
|
const wakeLockSentinel = ref<{ release: () => Promise<void> } | null>(null)
|
|
|
|
|
|
|
|
|
|
let clockTimer: ReturnType<typeof setInterval> | undefined
|
|
|
|
|
let cursorTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
|
let listTimer: ReturnType<typeof setInterval> | undefined
|
|
|
|
|
|
|
|
|
|
const brandName = computed(
|
|
|
|
|
() => String(getConfigApp('name_fa') || getConfigApp('name') || 'تابلو قیمت'),
|
|
|
|
|
)
|
|
|
|
|
const brandLogo = computed(() => String(getConfigApp('avatar') || logoFallback))
|
|
|
|
|
|
2026-08-22 21:50:39 +03:30
|
|
|
// API فقط show_on_tv میدهد؛ فیلتر کلاینت برای دادهٔ ناقص است
|
|
|
|
|
const catalogProducts = computed(() => {
|
|
|
|
|
const list = products.value
|
|
|
|
|
if (!list.length) return list
|
|
|
|
|
const marked = list.filter(isShowOnTv)
|
|
|
|
|
return marked.length ? marked : list
|
|
|
|
|
})
|
2026-08-20 10:06:55 +03:30
|
|
|
|
|
|
|
|
const parsianCoins = computed(() =>
|
|
|
|
|
catalogProducts.value
|
|
|
|
|
.filter(isParsianCoin)
|
|
|
|
|
.sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const bankCoins = computed(() =>
|
|
|
|
|
catalogProducts.value
|
|
|
|
|
.filter(isBankCoin)
|
2026-09-10 15:10:10 +03:30
|
|
|
.sort((a, b) => bankCoinRank(a) - bankCoinRank(b) || Number(a.amount ?? 0) - Number(b.amount ?? 0)),
|
2026-08-20 10:06:55 +03:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const hasCoinBoard = computed(
|
|
|
|
|
() => parsianCoins.value.length > 0 || bankCoins.value.length > 0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const coinRowCount = computed(() =>
|
|
|
|
|
Math.max(parsianCoins.value.length, bankCoins.value.length, 1),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const simorghBars = computed(() =>
|
|
|
|
|
catalogProducts.value
|
|
|
|
|
.filter(isSimorghBar)
|
|
|
|
|
.sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)),
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-10 15:10:10 +03:30
|
|
|
/** تیکر بالا: فقط شمشهای تابلو */
|
2026-08-22 22:05:59 +03:30
|
|
|
const tickerProducts = computed(() =>
|
|
|
|
|
catalogProducts.value
|
2026-09-10 15:10:10 +03:30
|
|
|
.filter(isGoldBar)
|
2026-08-22 22:05:59 +03:30
|
|
|
.sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)),
|
2026-08-22 21:50:39 +03:30
|
|
|
)
|
|
|
|
|
|
2026-09-10 15:10:10 +03:30
|
|
|
const tickerTitle = computed(() => (tickerProducts.value.length ? 'مظنه شمشها' : ''))
|
|
|
|
|
|
|
|
|
|
/** تیکر پایین: بقیه — نه سکه، نه پارسیان، نه شمش */
|
|
|
|
|
const extraTickerProducts = computed(() =>
|
|
|
|
|
catalogProducts.value
|
|
|
|
|
.filter((product) => !isParsianCoin(product) && !isBankCoin(product) && !isGoldBar(product))
|
|
|
|
|
.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '', 'fa')),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const extraTickerTitle = computed(() =>
|
|
|
|
|
extraTickerProducts.value.length ? 'سایر مظنهها' : '',
|
|
|
|
|
)
|
2026-08-22 22:05:59 +03:30
|
|
|
|
2026-08-22 21:50:39 +03:30
|
|
|
const hasBoardContent = computed(
|
2026-09-10 15:10:10 +03:30
|
|
|
() =>
|
|
|
|
|
hasCoinBoard.value
|
|
|
|
|
|| tickerProducts.value.length > 0
|
|
|
|
|
|| extraTickerProducts.value.length > 0,
|
2026-08-22 21:50:39 +03:30
|
|
|
)
|
|
|
|
|
|
2026-08-20 10:06:55 +03:30
|
|
|
const statusBanner = computed(() => {
|
|
|
|
|
void lastPrice.value
|
|
|
|
|
const state = getPriceDisplayState()
|
|
|
|
|
return state === 'open' ? '' : getPriceDisplayMessage(state)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const liveBuy = computed(() => getTradeBuyPrice(lastPrice.value))
|
|
|
|
|
const liveSell = computed(() => getTradeSellPrice(lastPrice.value))
|
|
|
|
|
|
|
|
|
|
const isLive = computed(
|
|
|
|
|
() => priceSocketConnected.value || priceStreamLifecycle.value === 'open',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async function loadProducts() {
|
|
|
|
|
try {
|
|
|
|
|
const list = await getTvProducts()
|
|
|
|
|
products.value = Array.isArray(list) ? list : []
|
|
|
|
|
} catch {
|
|
|
|
|
if (!products.value.length) {
|
|
|
|
|
products.value = []
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateClock() {
|
|
|
|
|
const now = moment()
|
|
|
|
|
nowLabel.value = now.format('HH:mm:ss')
|
|
|
|
|
dateLabel.value = now.format('dddd jD jMMMM jYYYY')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideCursorSoon() {
|
|
|
|
|
cursorHidden.value = false
|
|
|
|
|
if (cursorTimer) clearTimeout(cursorTimer)
|
|
|
|
|
cursorTimer = setTimeout(() => {
|
|
|
|
|
cursorHidden.value = true
|
|
|
|
|
}, CURSOR_HIDE_MS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function requestWakeLock() {
|
|
|
|
|
try {
|
|
|
|
|
const nav = navigator as Navigator & {
|
|
|
|
|
wakeLock?: { request: (type: 'screen') => Promise<{ release: () => Promise<void> }> }
|
|
|
|
|
}
|
|
|
|
|
wakeLockSentinel.value = (await nav.wakeLock?.request('screen')) ?? null
|
|
|
|
|
} catch {
|
|
|
|
|
wakeLockSentinel.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function releaseWakeLock() {
|
|
|
|
|
try {
|
|
|
|
|
await wakeLockSentinel.value?.release()
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
wakeLockSentinel.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onVisibilityChange() {
|
|
|
|
|
if (document.visibilityState === 'visible') {
|
|
|
|
|
await requestWakeLock()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 22:35:10 +03:30
|
|
|
async function toggleFullscreen(event?: Event) {
|
|
|
|
|
event?.stopPropagation()
|
2026-08-20 10:06:55 +03:30
|
|
|
hideCursorSoon()
|
2026-08-22 22:35:10 +03:30
|
|
|
try {
|
|
|
|
|
if (isFullscreen.value) {
|
|
|
|
|
await exitFullscreen()
|
|
|
|
|
} else {
|
2026-08-20 10:06:55 +03:30
|
|
|
await enterFullscreen()
|
|
|
|
|
}
|
2026-08-22 22:35:10 +03:30
|
|
|
} catch {
|
|
|
|
|
/* browser may block without gesture */
|
2026-08-20 10:06:55 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function productWeightLabel(product: Product) {
|
|
|
|
|
if (isMeltedTradeGroup(product.group_type)) {
|
|
|
|
|
return 'قیمت هر گرم'
|
|
|
|
|
}
|
|
|
|
|
const amount = Number(product.amount ?? 0)
|
|
|
|
|
if (!amount) return ''
|
|
|
|
|
if (amount < 1) return `${amount * 1000} سوت`
|
|
|
|
|
return `${amount} گرم`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function simorghWeightLabel(product: Product) {
|
|
|
|
|
const amount = Number(product.amount ?? 0)
|
|
|
|
|
if (!amount) return product.title
|
|
|
|
|
return `${amount} گرم`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 22:05:59 +03:30
|
|
|
function tickerItemLabel(product: Product) {
|
2026-09-10 15:10:10 +03:30
|
|
|
const title = (product.title ?? '').trim()
|
|
|
|
|
if (title) return title
|
|
|
|
|
return simorghWeightLabel(product)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isProductTvInactive(product: Product) {
|
|
|
|
|
return isTvProductPriceInactive(product)
|
2026-08-22 22:05:59 +03:30
|
|
|
}
|
|
|
|
|
|
2026-08-20 10:06:55 +03:30
|
|
|
function productBuyLabel(product: Product) {
|
2026-09-10 15:10:10 +03:30
|
|
|
if (isProductTvInactive(product)) return NO_QUOTE_MESSAGE
|
2026-09-06 21:35:54 +03:30
|
|
|
const value = getTvProductBuyPrice(product)
|
2026-08-20 10:06:55 +03:30
|
|
|
return value > 0 ? usePrice(value) : '—'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function productSellLabel(product: Product) {
|
2026-09-10 15:10:10 +03:30
|
|
|
if (isProductTvInactive(product)) return NO_QUOTE_MESSAGE
|
2026-09-06 21:35:54 +03:30
|
|
|
const value = getTvProductSellPrice(product)
|
2026-08-20 10:06:55 +03:30
|
|
|
return value > 0 ? usePrice(value) : '—'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function liveQuoteLabel(value: number) {
|
|
|
|
|
return value > 0 ? usePrice(value) : '—'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
document.documentElement.classList.add(TV_HTML_CLASS)
|
|
|
|
|
updateClock()
|
|
|
|
|
hideCursorSoon()
|
|
|
|
|
clockTimer = setInterval(updateClock, 1000)
|
|
|
|
|
window.addEventListener('mousemove', hideCursorSoon)
|
|
|
|
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
|
|
|
await requestWakeLock()
|
|
|
|
|
|
|
|
|
|
startLastPricePolling()
|
|
|
|
|
void getLastPrices()
|
|
|
|
|
void loadProducts()
|
|
|
|
|
listTimer = setInterval(() => {
|
|
|
|
|
void loadProducts()
|
|
|
|
|
}, REFRESH_MS)
|
|
|
|
|
|
|
|
|
|
document.title = `${brandName.value} | تابلو قیمت`
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
document.documentElement.classList.remove(TV_HTML_CLASS)
|
|
|
|
|
document.title = String(getConfigApp('name_fa') || getConfigApp('name') || '')
|
|
|
|
|
if (clockTimer) clearInterval(clockTimer)
|
|
|
|
|
if (cursorTimer) clearTimeout(cursorTimer)
|
|
|
|
|
if (listTimer) clearInterval(listTimer)
|
|
|
|
|
window.removeEventListener('mousemove', hideCursorSoon)
|
|
|
|
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
|
|
|
void releaseWakeLock()
|
|
|
|
|
stopLastPricePolling()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
rootEl,
|
|
|
|
|
brandName,
|
|
|
|
|
brandLogo,
|
|
|
|
|
nowLabel,
|
|
|
|
|
dateLabel,
|
|
|
|
|
parsianCoins,
|
|
|
|
|
bankCoins,
|
|
|
|
|
hasCoinBoard,
|
2026-08-22 21:50:39 +03:30
|
|
|
hasBoardContent,
|
2026-08-20 10:06:55 +03:30
|
|
|
coinRowCount,
|
2026-08-22 22:05:59 +03:30
|
|
|
tickerProducts,
|
|
|
|
|
tickerTitle,
|
2026-09-10 15:10:10 +03:30
|
|
|
extraTickerProducts,
|
|
|
|
|
extraTickerTitle,
|
2026-08-20 10:06:55 +03:30
|
|
|
statusBanner,
|
|
|
|
|
liveBuy,
|
|
|
|
|
liveSell,
|
|
|
|
|
isLive,
|
|
|
|
|
isFullscreen,
|
|
|
|
|
cursorHidden,
|
|
|
|
|
loading,
|
|
|
|
|
productWeightLabel,
|
2026-08-22 22:05:59 +03:30
|
|
|
tickerItemLabel,
|
2026-09-10 15:10:10 +03:30
|
|
|
isProductTvInactive,
|
2026-08-20 10:06:55 +03:30
|
|
|
productBuyLabel,
|
|
|
|
|
productSellLabel,
|
|
|
|
|
liveQuoteLabel,
|
2026-08-22 22:35:10 +03:30
|
|
|
toggleFullscreen,
|
2026-08-20 10:06:55 +03:30
|
|
|
}
|
|
|
|
|
}
|