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
@@ -0,0 +1,315 @@
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'
import { getProductBuyPrice, getProductSellPrice, isProductPriceInactive } from '@/utils/productPrice'
import { getLastPrices, getTradeBuyPrice, getTradeSellPrice, lastPrice, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import { getPriceDisplayMessage, getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
import { isMeltedTradeGroup } from '@/utils/productCategories'
import { priceSocketConnected, priceStreamLifecycle } from '@/utils/priceSocketState'
import { usePrice } from '@/utils/usePrice'
import logoFallback from '@/assets/img/boursemes_logofull.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
return value === true || value === 1 || value === '1' || value === 'true'
}
function isParsianCoin(product: Product) {
return productText(product).includes('پارسیان')
}
function isBankCoin(product: Product) {
if (isParsianCoin(product)) return false
const text = productText(product)
const group = (product.group_type ?? '').trim()
if (group === 'سکه' || group === 'سکه بانکی' || group.includes('بانکی')) return true
return text.includes('سکه') && /تمام|نیم|ربع/.test(text)
}
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 isBankCoinDenomination(product: Product) {
return bankCoinRank(product) <= 3
}
function isSimorghBar(product: Product) {
return productText(product).includes('سیمرغ')
}
export function useTvMonitorPage() {
const rootEl = ref<HTMLElement | null>(null)
const { isFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(rootEl)
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))
// 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
})
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)
.filter(isBankCoinDenomination)
.sort((a, b) => bankCoinRank(a) - bankCoinRank(b) || Number(b.amount ?? 0) - Number(a.amount ?? 0)),
)
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)),
)
/** تیکر پایین: شمش / آبشده / هر محصول غیرسکه */
const tickerProducts = computed(() =>
catalogProducts.value
.filter((p) => !isParsianCoin(p) && !isBankCoin(p))
.sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)),
)
const tickerTitle = computed(() => {
const items = tickerProducts.value
if (!items.length) return ''
if (items.every(isSimorghBar)) return 'شمش سیمرغ'
if (items.every((p) => isMeltedTradeGroup(p.group_type))) return 'آبشده'
return 'محصولات تابلو'
})
const hasBoardContent = computed(
() => hasCoinBoard.value || tickerProducts.value.length > 0,
)
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()
}
}
async function toggleFullscreen(event?: Event) {
event?.stopPropagation()
hideCursorSoon()
try {
if (isFullscreen.value) {
await exitFullscreen()
} else {
await enterFullscreen()
}
} catch {
/* browser may block without gesture */
}
}
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} گرم`
}
function tickerItemLabel(product: Product) {
if (isSimorghBar(product)) return simorghWeightLabel(product)
const amount = Number(product.amount ?? 0)
if (isMeltedTradeGroup(product.group_type)) {
return amount > 0 ? `${product.title} · ${amount} گرم` : product.title
}
return amount > 0 ? `${product.title} · ${amount} گرم` : product.title
}
function productBuyLabel(product: Product) {
if (isProductPriceInactive(product)) return NO_QUOTE_MESSAGE
const value = getProductBuyPrice(product)
return value > 0 ? usePrice(value) : '—'
}
function productSellLabel(product: Product) {
if (isProductPriceInactive(product)) return NO_QUOTE_MESSAGE
const value = getProductSellPrice(product)
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,
hasBoardContent,
coinRowCount,
tickerProducts,
tickerTitle,
statusBanner,
liveBuy,
liveSell,
isLive,
isFullscreen,
cursorHidden,
loading,
productWeightLabel,
tickerItemLabel,
productBuyLabel,
productSellLabel,
liveQuoteLabel,
toggleFullscreen,
}
}
+211
View File
@@ -0,0 +1,211 @@
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { colors } from '@/themeConfig'
export type TvThemeMode = 'light' | 'dark'
const STORAGE_KEY = 'boursecu_tv_theme'
type Rgb = { r: number; g: number; b: number }
const appWhite = String(colors.white)
const appGray50 = String(colors.gray[50])
const appGray100 = String(colors.gray[100])
const appGray500 = String(colors.gray[500])
const appGray700 = String(colors.gray[700])
const appSecondary = String(colors.secondary)
const appTertiary = String(colors.tertiary)
function normalizeHex(hex: unknown): string | null {
if (!hex || typeof hex !== 'string') return null
let value = hex.trim()
if (!value) return null
if (value[0] !== '#') value = `#${value}`
if (/^#[0-9a-fA-F]{3}$/.test(value)) {
value = `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
}
if (!/^#[0-9a-fA-F]{6}$/.test(value)) return null
return value.toLowerCase()
}
function readAppPrimary(): string {
try {
const css = getComputedStyle(document.documentElement)
.getPropertyValue('--App-primary-color')
.trim()
return normalizeHex(css) || normalizeHex(String(colors.secondary)) || '#135953'
} catch {
return normalizeHex(String(colors.secondary)) || '#135953'
}
}
function hexToRgb(hex: string): Rgb | null {
const normalized = normalizeHex(hex)
if (!normalized) return null
const num = parseInt(normalized.slice(1), 16)
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
}
}
function rgbToHex(r: number, g: number, b: number) {
const toHex = (value: number) =>
Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function mix(hexA: string, hexB: string, weight: number) {
const a = hexToRgb(hexA)
const b = hexToRgb(hexB)
if (!a || !b) return hexA
return rgbToHex(
a.r * (1 - weight) + b.r * weight,
a.g * (1 - weight) + b.g * weight,
a.b * (1 - weight) + b.b * weight,
)
}
function brightness(hex: string) {
const rgb = hexToRgb(hex)
if (!rgb) return 0
return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
}
function hexToRgba(hex: string, alpha: number) {
const rgb = hexToRgb(hex)
if (!rgb) return `rgba(15, 23, 42, ${alpha})`
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`
}
function toneForSurface(hex: string, surface: 'light' | 'dark') {
const value = brightness(hex)
if (surface === 'light') {
return value > 165 ? mix(hex, '#1e2229', 0.42) : hex
}
return value < 95 ? mix(hex, appWhite, 0.42) : hex
}
function readStoredTheme(): TvThemeMode | null {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') return stored
} catch {
/* ignore */
}
return null
}
function buildThemeVars(mode: TvThemeMode) {
const isDark = mode === 'dark'
const appPrimary = readAppPrimary()
const pageBg = isDark ? mix('#160910', appPrimary, 0.22) : appWhite
const pageBgSoft = isDark
? mix(pageBg, appPrimary, 0.18)
: mix(appGray50, appPrimary, 0.08)
const text = isDark ? appWhite : '#1e2229'
const muted = isDark ? hexToRgba(appWhite, 0.72) : appGray500
const card = isDark ? mix(pageBg, appWhite, 0.08) : appWhite
const cardText = isDark ? appWhite : '#1e2229'
const cardMuted = isDark ? hexToRgba(appWhite, 0.68) : appGray500
const border = isDark ? hexToRgba(appWhite, 0.12) : hexToRgba(appPrimary, 0.14)
const mediaBg = isDark ? hexToRgba(appWhite, 0.08) : mix(appGray100, appPrimary, 0.06)
const shadow = isDark
? `0 16px 40px ${hexToRgba(appPrimary, 0.28)}`
: `0 14px 32px ${hexToRgba(appPrimary, 0.14)}`
const sell = toneForSurface(appSecondary, isDark ? 'dark' : 'light')
const buy = toneForSurface(appPrimary, isDark ? 'dark' : 'light')
const sellBg = isDark ? hexToRgba(appSecondary, 0.2) : mix(appWhite, appSecondary, 0.14)
const buyBg = isDark ? hexToRgba(appPrimary, 0.22) : mix(appWhite, appPrimary, 0.12)
return {
'--tv-bg': pageBg,
'--tv-bg-soft': pageBgSoft,
'--tv-text': text,
'--tv-muted': muted,
'--tv-card': card,
'--tv-card-text': cardText,
'--tv-card-muted': cardMuted,
'--tv-border': border,
'--tv-primary': appPrimary,
'--tv-secondary': appSecondary,
'--tv-tertiary': appTertiary,
'--tv-sell': sell,
'--tv-buy': buy,
'--tv-sell-bg': sellBg,
'--tv-buy-bg': buyBg,
'--tv-media': mediaBg,
'--tv-logo-bg': appWhite,
'--tv-shadow': shadow,
'--tv-orb-1': hexToRgba(appPrimary, isDark ? 0.28 : 0.14),
'--tv-orb-2': hexToRgba(appTertiary, isDark ? 0.16 : 0.1),
'--tv-quote-bg': isDark ? hexToRgba(appWhite, 0.07) : hexToRgba(appWhite, 0.86),
'--tv-dot': isDark ? hexToRgba(appWhite, 0.28) : hexToRgba(appPrimary, 0.22),
'--tv-badge': isDark ? mix(appPrimary, '#1e2229', 0.35) : mix(appPrimary, '#1e2229', 0.2),
}
}
export function useTvTheme() {
const route = useRoute()
const router = useRouter()
function resolveTheme(): TvThemeMode {
const query = String(route.query.theme || '').toLowerCase()
if (query === 'light' || query === 'dark') return query
return readStoredTheme() ?? 'dark'
}
const theme = ref<TvThemeMode>(resolveTheme())
const themeVars = computed(() => buildThemeVars(theme.value))
watch(
theme,
(mode) => {
document.documentElement.classList.toggle('app-layout--tv-dark', mode === 'dark')
document.documentElement.classList.toggle('app-layout--tv-light', mode === 'light')
document.documentElement.style.setProperty('--tv-page-bg', themeVars.value['--tv-bg'])
},
{ immediate: true },
)
watch(
() => route.query.theme,
(value) => {
if (value === 'light' || value === 'dark') {
theme.value = value
}
},
)
onUnmounted(() => {
document.documentElement.classList.remove('app-layout--tv-dark', 'app-layout--tv-light')
document.documentElement.style.removeProperty('--tv-page-bg')
})
function persistTheme(next: TvThemeMode) {
theme.value = next
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {
/* ignore */
}
const nextQuery = { ...route.query, theme: next }
void router.replace({ query: nextQuery })
}
function toggleTheme(event?: Event) {
event?.stopPropagation()
persistTheme(theme.value === 'dark' ? 'light' : 'dark')
}
return {
theme,
themeVars,
toggleTheme,
}
}
+926
View File
@@ -0,0 +1,926 @@
<template>
<div
ref="rootEl"
class="tv-monitor"
:class="[`tv-monitor--${theme}`, { 'tv-monitor--idle': cursorHidden }]"
:style="themeVars"
dir="rtl"
>
<header class="tv-monitor__header">
<div class="tv-monitor__brand">
<img :src="brandLogo" :alt="brandName" class="tv-monitor__logo" />
<div>
<h1 class="tv-monitor__title">{{ brandName }}</h1>
<p class="tv-monitor__subtitle">تابلو قیمت محصولات</p>
</div>
</div>
<div class="tv-monitor__quote">
<span class="tv-monitor__quote-label">مظنه لحظه‌ای</span>
<div class="tv-monitor__quote-prices">
<div class="tv-monitor__quote-item tv-monitor__quote-item--sell">
<span>فروش</span>
<strong>{{ liveQuoteLabel(liveBuy) }}</strong>
</div>
<div class="tv-monitor__quote-item tv-monitor__quote-item--buy">
<span>خرید</span>
<strong>{{ liveQuoteLabel(liveSell) }}</strong>
</div>
</div>
</div>
<div class="tv-monitor__clock">
<div class="tv-monitor__toolbar">
<button
type="button"
class="tv-monitor__theme-toggle"
:title="theme === 'dark' ? 'تم روشن' : 'تم تیره'"
@click="toggleTheme"
>
<BasicIcon :icon="theme === 'dark' ? 'vuesax-linear:sun-1' : 'vuesax-linear:moon'" size="18" />
<span>{{ theme === 'dark' ? 'تم روشن' : 'تم تیره' }}</span>
</button>
<button
type="button"
class="tv-monitor__theme-toggle"
:title="isFullscreen ? 'خروج از تمام‌صفحه' : 'تمام‌صفحه'"
@click="toggleFullscreen"
>
<BasicIcon
:icon="isFullscreen ? 'vuesax-linear:maximize-circle' : 'vuesax-linear:maximize-4'"
size="18"
/>
<span>{{ isFullscreen ? 'خروج تمام‌صفحه' : 'تمام‌صفحه' }}</span>
</button>
</div>
<div class="tv-monitor__time">{{ nowLabel }}</div>
<div class="tv-monitor__date">{{ dateLabel }}</div>
<div class="tv-monitor__live" :class="{ 'is-on': isLive }">
<span class="tv-monitor__live-dot" />
{{ isLive ? 'زنده' : 'به‌روزرسانی خودکار' }}
</div>
</div>
</header>
<p v-if="statusBanner" class="tv-monitor__banner">{{ statusBanner }}</p>
<main class="tv-monitor__board">
<a-spin :spinning="loading" class="tv-monitor__spin">
<div
v-if="hasCoinBoard"
class="tv-monitor__columns"
:style="{ '--tv-row-count': coinRowCount }"
>
<section class="tv-col">
<header class="tv-col__head">
<h2>سکه پارسیان</h2>
<span>تمام اوزان</span>
</header>
<div class="tv-col__labels">
<span class="tv-col__labels-gap" />
<div class="tv-col__price-heads">
<span class="tv-col__label tv-col__label--sell">فروش</span>
<span class="tv-col__label tv-col__label--buy">خرید</span>
</div>
</div>
<div class="tv-col__list">
<article
v-for="item in parsianCoins"
:key="item.id"
class="tv-row"
>
<div class="tv-row__media">
<ProductImage :product="item" img-class="tv-row__img" />
</div>
<div class="tv-row__meta">
<h3>{{ item.title }}</h3>
<p>{{ productWeightLabel(item) }}</p>
</div>
<div class="tv-row__prices">
<div class="tv-row__price tv-row__price--sell">
<strong>{{ productBuyLabel(item) }}</strong>
</div>
<div class="tv-row__price tv-row__price--buy">
<strong>{{ productSellLabel(item) }}</strong>
</div>
</div>
</article>
<div v-if="!parsianCoins.length" class="tv-col__empty">سکه‌ای برای نمایش نیست</div>
</div>
</section>
<section class="tv-col">
<header class="tv-col__head">
<h2>سکه بانکی</h2>
<span>تمام · نیم · ربع</span>
</header>
<div class="tv-col__labels">
<span class="tv-col__labels-gap" />
<div class="tv-col__price-heads">
<span class="tv-col__label tv-col__label--sell">فروش</span>
<span class="tv-col__label tv-col__label--buy">خرید</span>
</div>
</div>
<div class="tv-col__list">
<article
v-for="item in bankCoins"
:key="item.id"
class="tv-row"
>
<div class="tv-row__media">
<ProductImage :product="item" img-class="tv-row__img" />
</div>
<div class="tv-row__meta">
<h3>{{ item.title }}</h3>
<p>{{ productWeightLabel(item) }}</p>
</div>
<div class="tv-row__prices">
<div class="tv-row__price tv-row__price--sell">
<strong>{{ productBuyLabel(item) }}</strong>
</div>
<div class="tv-row__price tv-row__price--buy">
<strong>{{ productSellLabel(item) }}</strong>
</div>
</div>
</article>
<div v-if="!bankCoins.length" class="tv-col__empty">سکه‌ای برای نمایش نیست</div>
</div>
</section>
</div>
<div v-else-if="!loading && !hasBoardContent" class="tv-monitor__empty">
محصولی برای نمایش روی تابلو انتخاب نشده است
</div>
</a-spin>
</main>
<footer class="tv-monitor__footer">
<div
v-if="tickerProducts.length"
class="tv-ticker"
:style="{ '--tv-ticker-duration': `${Math.max(tickerProducts.length * 7, 36)}s` }"
>
<div class="tv-ticker__label">
<strong>{{ tickerTitle }}</strong>
<span>خرید و فروش</span>
</div>
<div class="tv-ticker__viewport">
<div class="tv-ticker__track">
<article
v-for="item in tickerProducts"
:key="item.id"
class="tv-ticker__item"
>
<span class="tv-ticker__weight">{{ tickerItemLabel(item) }}</span>
<span class="tv-ticker__price tv-ticker__price--sell">
فروش
<b>{{ productBuyLabel(item) }}</b>
</span>
<span class="tv-ticker__price tv-ticker__price--buy">
خرید
<b>{{ productSellLabel(item) }}</b>
</span>
</article>
</div>
</div>
</div>
<div class="tv-monitor__credit" @click.stop>
<AppCreditLink variant="tv" />
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import ProductImage from '@/components/ProductImage.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import AppCreditLink from '@/components/AppCreditLink.vue'
import { useTvMonitorPage } from '@/views/tv/composables/useTvMonitorPage'
import { useTvTheme } from '@/views/tv/composables/useTvTheme'
const { theme, themeVars, toggleTheme } = useTvTheme()
const {
rootEl,
brandName,
brandLogo,
nowLabel,
dateLabel,
parsianCoins,
bankCoins,
hasCoinBoard,
hasBoardContent,
coinRowCount,
tickerProducts,
tickerTitle,
statusBanner,
liveBuy,
liveSell,
isLive,
isFullscreen,
cursorHidden,
loading,
productWeightLabel,
tickerItemLabel,
productBuyLabel,
productSellLabel,
liveQuoteLabel,
toggleFullscreen,
} = useTvMonitorPage()
</script>
<style scoped lang="less">
.tv-monitor {
box-sizing: border-box;
display: flex;
position: relative;
flex-direction: column;
width: 100%;
height: 100%;
padding: clamp(12px, 1.6vh, 24px) clamp(16px, 2vw, 32px);
background:
radial-gradient(1200px 500px at 100% -10%, var(--tv-orb-1), transparent 55%),
radial-gradient(900px 420px at -10% 110%, var(--tv-orb-2), transparent 50%),
linear-gradient(160deg, var(--tv-bg) 0%, var(--tv-bg-soft) 48%, var(--tv-bg) 100%);
color: var(--tv-text);
font-family: iran-sans, sans-serif;
user-select: none;
overflow: hidden;
h1,
h2,
p,
span,
strong,
div,
article {
font-family: inherit;
}
&--idle {
cursor: none;
}
}
.tv-monitor__header {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.9fr;
align-items: center;
gap: 16px;
margin-bottom: clamp(10px, 1.4vh, 20px);
}
.tv-monitor__banner {
margin: 0 0 clamp(10px, 1.2vh, 16px);
padding: 10px 16px;
border-radius: 14px;
background: var(--tv-quote-bg);
border: 1px solid var(--tv-border);
color: var(--tv-muted);
font-size: clamp(12px, 1.05vw, 15px);
text-align: center;
}
.tv-monitor__brand {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.tv-monitor__logo {
width: clamp(52px, 6.5vh, 76px);
height: clamp(52px, 6.5vh, 76px);
object-fit: contain;
background: var(--tv-logo-bg);
border-radius: 16px;
padding: 6px;
}
.tv-monitor__title {
margin: 0;
font-size: clamp(20px, 2.4vw, 36px);
font-weight: 700;
line-height: 1.3;
color: var(--tv-text);
}
.tv-monitor__subtitle {
margin: 2px 0 0;
color: var(--tv-muted);
font-size: clamp(12px, 1.1vw, 16px);
}
.tv-monitor__quote {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.tv-monitor__quote-label {
font-size: 13px;
color: var(--tv-muted);
}
.tv-monitor__quote-prices {
display: flex;
gap: 10px;
}
.tv-monitor__quote-item {
min-width: 180px;
padding: 8px 14px;
border-radius: 14px;
text-align: center;
background: var(--tv-quote-bg);
border: 1px solid var(--tv-border);
span {
display: block;
font-size: 12px;
color: var(--tv-muted);
}
strong {
display: block;
margin-top: 2px;
font-size: clamp(16px, 1.5vw, 22px);
font-weight: 700;
font-family: iran-sans, sans-serif;
direction: ltr;
unicode-bidi: isolate;
}
&--sell strong {
color: var(--tv-sell);
}
&--buy strong {
color: var(--tv-buy);
}
}
.tv-monitor__clock {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: left;
}
.tv-monitor__toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
margin-bottom: 8px;
}
.tv-monitor__theme-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 0;
padding: 6px 12px;
border: 1px solid var(--tv-border);
border-radius: 999px;
background: var(--tv-quote-bg);
color: var(--tv-text);
font-family: iran-sans, sans-serif;
font-size: 12px;
cursor: pointer;
:deep(svg path) {
stroke: currentColor;
}
}
.tv-monitor__time {
font-size: clamp(28px, 3.4vw, 48px);
font-weight: 700;
letter-spacing: 0.04em;
font-family: iran-sans, sans-serif;
direction: ltr;
unicode-bidi: isolate;
line-height: 1;
color: var(--tv-text);
}
.tv-monitor__date {
margin-top: 6px;
color: var(--tv-muted);
font-size: clamp(12px, 1.15vw, 16px);
}
.tv-monitor__live {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
color: var(--tv-muted);
font-size: 12px;
&.is-on {
color: var(--tv-sell);
}
}
.tv-monitor__live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tv-muted);
.is-on & {
background: var(--tv-sell);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--tv-sell) 70%, transparent);
animation: tv-pulse 1.6s infinite;
}
}
.tv-monitor__board {
flex: 1;
min-height: 0;
:deep(.tv-monitor__spin),
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
}
}
.tv-monitor__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(12px, 1.6vw, 22px);
height: 100%;
min-height: 0;
}
.tv-col {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
padding: 12px;
border-radius: 22px;
background: var(--tv-quote-bg);
border: 1px solid var(--tv-border);
}
.tv-col__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding: 0 4px 10px;
border-bottom: 1px solid var(--tv-border);
h2 {
margin: 0;
font-size: clamp(18px, 1.7vw, 28px);
font-weight: 700;
color: var(--tv-text);
}
span {
color: var(--tv-muted);
font-size: clamp(12px, 1vw, 15px);
}
}
.tv-col__labels {
display: grid;
grid-template-columns: 56px minmax(0, 0.9fr) minmax(0, 2fr);
gap: 10px;
align-items: center;
margin-bottom: 8px;
padding: 0 10px;
}
.tv-col__labels-gap {
grid-column: 1 / 3;
}
.tv-col__price-heads {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.tv-col__label {
display: block;
text-align: center;
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 700;
}
.tv-col__label--sell {
color: var(--tv-sell);
}
.tv-col__label--buy {
color: var(--tv-buy);
}
.tv-col__list {
display: grid;
flex: 1;
min-height: 0;
/* با آیتم کم کش نیاید؛ با آیتم زیاد فضای ستون را پر کند */
grid-auto-rows: minmax(
64px,
min(88px, calc((100% - (var(--tv-row-count) - 1) * 8px) / var(--tv-row-count)))
);
align-content: start;
gap: 8px;
overflow: auto;
}
.tv-col__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
color: var(--tv-muted);
font-size: 16px;
}
.tv-row {
position: relative;
display: grid;
grid-template-columns: minmax(44px, 56px) minmax(0, 0.9fr) minmax(0, 2fr);
align-items: center;
gap: 10px;
height: 100%;
max-height: 88px;
min-height: 0;
min-width: 0;
overflow: hidden;
padding: 8px 12px;
border-radius: 16px;
background: var(--tv-card);
color: var(--tv-card-text);
border: 1px solid var(--tv-border);
box-shadow: var(--tv-shadow);
container-type: size;
&--off {
opacity: 0.72;
}
}
.tv-row__badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 7px;
border-radius: 8px;
background: var(--tv-badge);
color: #fff;
font-size: 11px;
}
.tv-row__media {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
height: 100%;
min-height: 0;
border-radius: 12px;
background: var(--tv-media);
}
.tv-row__img {
max-width: 86%;
max-height: 86%;
object-fit: contain;
}
.tv-row__meta {
min-width: 0;
overflow: hidden;
h3 {
margin: 0;
overflow: hidden;
font-size: clamp(13px, 1.2vw, 20px);
font-weight: 700;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--tv-card-text);
}
p {
margin: 4px 0 0;
overflow: hidden;
color: var(--tv-card-muted);
font-size: clamp(11px, 1vw, 15px);
text-overflow: ellipsis;
white-space: nowrap;
}
}
@container (max-height: 64px) {
.tv-row__meta p {
display: none;
}
}
.tv-row__prices {
display: grid;
align-self: stretch;
grid-template-columns: 1fr 1fr;
gap: 8px;
height: 100%;
min-height: 0;
}
.tv-row__price {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
padding: 6px 8px;
border-radius: 12px;
text-align: center;
container-type: inline-size;
span {
display: block;
font-size: 12px;
color: var(--tv-card-muted);
}
strong {
display: block;
max-width: 100%;
margin-top: 0;
overflow: hidden;
font-size: clamp(10px, 12cqi, 16px);
font-weight: 700;
font-family: iran-sans, sans-serif;
white-space: nowrap;
direction: ltr;
unicode-bidi: isolate;
}
&--sell {
background: var(--tv-sell-bg);
strong {
color: var(--tv-sell);
}
}
&--buy {
background: var(--tv-buy-bg);
strong {
color: var(--tv-buy);
}
}
}
.tv-monitor__empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--tv-muted);
font-size: 22px;
}
.tv-monitor__footer {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 12px;
margin-inline: calc(-1 * clamp(16px, 2vw, 32px));
margin-bottom: calc(-1 * clamp(12px, 1.6vh, 24px));
}
.tv-monitor__credit {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
background: #fff;
box-shadow: 0 -2px 4px -2px #0000001a;
}
.tv-ticker {
display: flex;
overflow: hidden;
min-height: 52px;
background: var(--tv-primary);
color: #fff;
box-shadow: 0 -8px 24px color-mix(in srgb, var(--tv-primary) 28%, transparent);
}
.tv-ticker__label {
display: flex;
position: relative;
z-index: 2;
flex-shrink: 0;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 132px;
padding: 8px 18px 8px 22px;
background: color-mix(in srgb, #1e2229 42%, var(--tv-primary));
strong {
font-size: clamp(14px, 1.2vw, 18px);
font-weight: 700;
line-height: 1.3;
}
span {
font-size: 11px;
opacity: 0.82;
}
&::before {
position: absolute;
top: 0;
bottom: 0;
left: -18px;
width: 18px;
background: inherit;
clip-path: polygon(100% 0, 100% 100%, 0 50%);
content: '';
}
}
.tv-ticker__viewport {
display: flex;
overflow: hidden;
flex: 1;
align-items: center;
justify-content: flex-start;
min-width: 0;
direction: ltr;
container-type: inline-size;
mask-image: linear-gradient(to left, transparent 0, #000 36px);
}
.tv-ticker__track {
display: flex;
flex-shrink: 0;
align-items: center;
width: max-content;
direction: rtl;
will-change: transform;
animation: tv-ticker var(--tv-ticker-duration, 40s) linear infinite;
}
.tv-ticker__item {
display: flex;
align-items: center;
gap: 14px;
padding: 0 28px;
white-space: nowrap;
border-left: 1px solid color-mix(in srgb, #fff 28%, transparent);
}
.tv-ticker__weight {
font-size: clamp(14px, 1.15vw, 18px);
font-weight: 700;
}
.tv-ticker__price {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
b {
font-size: clamp(13px, 1.05vw, 16px);
font-weight: 700;
font-family: iran-sans, sans-serif;
white-space: nowrap;
direction: ltr;
unicode-bidi: isolate;
}
&--sell b {
color: #ffe08a;
}
&--buy b {
color: #fff;
}
}
@keyframes tv-ticker {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100cqw);
}
}
@keyframes tv-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--tv-sell) 55%, transparent);
}
70% {
box-shadow: 0 0 0 8px transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@media (max-width: 1100px) {
.tv-monitor__header {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.2fr) auto;
gap: 10px;
}
.tv-monitor__logo {
width: 48px;
height: 48px;
}
.tv-monitor__quote,
.tv-monitor__quote-item {
min-width: 0;
}
.tv-col__labels,
.tv-row {
grid-template-columns: minmax(40px, 48px) minmax(0, 0.85fr) minmax(0, 2.1fr);
}
}
@media (max-width: 720px) {
.tv-monitor__header {
grid-template-columns: 1fr auto;
}
.tv-monitor__quote {
grid-column: 1 / -1;
order: 3;
}
.tv-monitor__columns {
grid-template-columns: 1fr;
overflow: auto;
}
.tv-col {
min-height: 240px;
}
}
</style>
<style lang="less">
html.app-layout--tv,
html.app-layout--tv body,
html.app-layout--tv #app,
html.app-layout--tv .desktopConfig,
html.app-layout--tv .ant-config-provider,
html.app-layout--tv .app-main,
html.app-layout--tv .tv-monitor {
font-family: iran-sans, sans-serif !important;
}
html.app-layout--tv,
html.app-layout--tv body,
html.app-layout--tv #app,
html.app-layout--tv .desktopConfig,
html.app-layout--tv .ant-config-provider,
html.app-layout--tv .app-main {
width: 100% !important;
max-width: none !important;
height: 100dvh !important;
min-height: 100dvh !important;
margin: 0 !important;
overflow: hidden !important;
}
html.app-layout--tv .tv-monitor {
width: 100% !important;
height: 100% !important;
}
html.app-layout--tv body {
display: block !important;
background: var(--tv-page-bg, #0b1220) !important;
}
html.app-layout--tv .app-splash-screen,
html.app-layout--tv #support-button,
html.app-layout--tv #goftino_w {
display: none !important;
}
</style>