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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user