diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index 3760fa3..95c2708 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -17,6 +17,7 @@ export interface AppSetting { export const useSettingsStore = defineStore('settings', () => { const settings = ref([]) const loaded = ref(false) + const settingsRevision = ref(0) let fetchPromise: Promise | null = null function getSettingByKey(key: string) { @@ -42,6 +43,7 @@ export const useSettingsStore = defineStore('settings', () => { } const merged = [...settings.value] + let changed = false items.forEach((item) => { if (!item?.key) { return @@ -49,13 +51,25 @@ export const useSettingsStore = defineStore('settings', () => { const index = merged.findIndex((setting) => setting.key === item.key) if (index >= 0) { - merged[index] = { ...merged[index], ...item } - } else { - merged.push(item) + const current = merged[index] + if (current.value === item.value && current.is_active === item.is_active) { + return + } + merged[index] = { ...current, ...item } + changed = true + return } + + merged.push(item) + changed = true }) + if (!changed) { + return + } + settings.value = merged + settingsRevision.value += 1 } async function fetchSettings(force = false) { @@ -89,6 +103,7 @@ export const useSettingsStore = defineStore('settings', () => { return { settings, loaded, + settingsRevision, fetchSettings, mergeSettings, getSettingByKey, diff --git a/src/utils/price.ts b/src/utils/price.ts index e2162de..460b955 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -8,6 +8,7 @@ import { shouldUsePricePolling, } from '@/utils/priceSocketState' import { applyBoardSpecialToLastPayload } from '@/utils/specialPricing' +import { useSettingsStore, type AppSetting } from '@/store/settingsStore' const LAST_INTERVAL_MS = 10000 @@ -47,6 +48,19 @@ function hasValidPricePayload(data?: Price | null): boolean { ) } +function applyTradingHoursSettingsPatch(data: unknown) { + if (!data || typeof data !== 'object') { + return + } + + const patch = (data as { settings_patch?: unknown }).settings_patch + if (!Array.isArray(patch) || patch.length === 0) { + return + } + + useSettingsStore().mergeSettings(patch as AppSetting[]) +} + export function markPriceSourceReceived() { lastPriceSourceAt = Date.now() consecutivePriceFetchFailures = 0 @@ -170,6 +184,7 @@ export async function getLastPrices(): Promise { } export function applyLastPricePayload(dataLast: Price) { + applyTradingHoursSettingsPatch(dataLast) const normalized = normalizePriceResponse(applyBoardSpecialToLastPayload(dataLast as any) as Price) const nextBuy = getTradeBuyPrice(normalized) const prevBuy = getTradeBuyPrice(lastPrice.value) diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index 99bc306..8c88318 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -399,13 +399,40 @@ async function applyLastFromStream(last: Price) { applyLastPricePayload(last) } +async function applySettingsFromStream(payload: unknown) { + const items = Array.isArray(payload) + ? payload + : payload && typeof payload === 'object' && Array.isArray((payload as { items?: unknown }).items) + ? (payload as { items: unknown[] }).items + : null + if (!items?.length) { + return + } + + try { + const { useSettingsStore } = await import('@/store/settingsStore') + const { touchStoreHoursSettings } = await import('@/utils/storeHours') + useSettingsStore().mergeSettings(items as Array<{ key?: string; value?: unknown }>) + touchStoreHoursSettings() + } catch (error) { + console.warn('Settings SSE apply failed', error) + } +} + function handleLastPayload(payload: unknown) { if (!payload || typeof payload !== 'object') { return } - const last = applyBoardSpecialToLastPayload(payload as Price) - if (last.price_last == null && last.price == null && last.buy == null) { + const raw = payload as Price & { settings_patch?: unknown } + const last = applyBoardSpecialToLastPayload(raw) + const hasQuote = last.price_last != null || last.price != null || last.buy != null + const hasStatus = last.status != null || typeof last.closed === 'boolean' + if (!hasQuote && !hasStatus) { + if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) { + touchStreamActivity() + void applySettingsFromStream(raw.settings_patch) + } return } @@ -413,6 +440,11 @@ function handleLastPayload(payload: unknown) { void applyLastFromStream(last) } +function handleSettingsPayload(payload: unknown) { + touchStreamActivity() + void applySettingsFromStream(payload) +} + function handleListPayload(payload: unknown) { if (!Array.isArray(payload) || !payload.length) { return @@ -467,6 +499,14 @@ function bindStreamEvents(source: AuthPriceStream) { } }) + source.addEventListener('settings', (event) => { + try { + handleSettingsPayload(JSON.parse(event.data)) + } catch (error) { + console.warn('Price SSE settings parse failed', error) + } + }) + source.addEventListener('list', (event) => { try { handleListPayload(JSON.parse(event.data)) diff --git a/src/utils/storeHours.ts b/src/utils/storeHours.ts index 5232cc3..6a8d325 100644 --- a/src/utils/storeHours.ts +++ b/src/utils/storeHours.ts @@ -1,6 +1,6 @@ import { ref } from 'vue' import { useSettingsStore } from '@/store/settingsStore' -import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price' +import { lastPrice } from '@/utils/price' export interface StoreTradingHours { start: string @@ -12,8 +12,24 @@ export interface EffectiveTradingHours { start: string end: string enforced: boolean + dayEnabled: boolean } +type WeekdayKey = 'sat' | 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' + +type DaySchedule = { + enabled: boolean + start: string + end: string +} + +type WeeklySchedule = { + mode: 'uniform' | 'per_day' + days: Record +} + +const WEEKDAY_BY_JS: WeekdayKey[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] + const productTradingHours = ref<{ start: string; end: string } | null>(null) const productTradingHoursVersion = ref(0) @@ -54,6 +70,11 @@ function hasProductTradingWindow(start?: string | null, end?: string | null): bo return open !== close } +/** بعد از merge زندهٔ تنظیمات ساعت کاری — computedهای صفحه اصلی دوباره حساب شوند */ +export function touchStoreHoursSettings() { + productTradingHoursVersion.value += 1 +} + export function setProductTradingHours(start?: string | null, end?: string | null) { if (!hasProductTradingWindow(start, end)) { productTradingHours.value = null @@ -81,29 +102,77 @@ export function syncProductTradingHoursFromProducts( setProductTradingHours(null, null) } -export function getStoreTradingHours(): StoreTradingHours { - const settings = useSettingsStore() - const closeRaw = settings.getSettingByKey('close_price') - const closePriceEnabled = - closeRaw === true || - closeRaw === 1 || - String(closeRaw ?? 'false').trim().toLowerCase() === 'true' +function weekdayKey(now: Date): WeekdayKey { + return WEEKDAY_BY_JS[now.getDay()] ?? 'sat' +} - return { - start: normalizeTradingTimeSetting(settings.getSettingByKey('startDay'), '00:00'), - end: normalizeTradingTimeSetting(settings.getSettingByKey('endDay'), '23:59'), - closePriceEnabled, +function parseWeeklySchedule(raw: unknown): WeeklySchedule | null { + const text = String(raw ?? '').trim() + if (!text) return null + try { + const parsed = JSON.parse(text) as Partial + if (!parsed?.days || typeof parsed.days !== 'object') return null + const mode = parsed.mode === 'per_day' ? 'per_day' : 'uniform' + const defaults = getStoreTradingHours() + const keys: WeekdayKey[] = ['sat', 'sun', 'mon', 'tue', 'wed', 'thu', 'fri'] + const days = {} as Record + for (const key of keys) { + const row = parsed.days[key] + days[key] = { + enabled: row?.enabled !== false, + start: normalizeTradingTimeSetting(row?.start, defaults.start), + end: normalizeTradingTimeSetting(row?.end, defaults.end), + } + } + return { mode, days } + } catch { + return null } } -export function getEffectiveTradingHours(): EffectiveTradingHours { +function isOutsideTimeWindow(now: Date, start: string, end: string): boolean { + const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() + const openSeconds = parseTimeToSeconds(start) + const closeSeconds = parseTimeToSeconds(end) + if (closeSeconds <= openSeconds) { + return currentSeconds < openSeconds && currentSeconds > closeSeconds + } + return currentSeconds < openSeconds || currentSeconds > closeSeconds +} + +export function getStoreTradingHours(): StoreTradingHours { + const settings = useSettingsStore() + void settings.settingsRevision + return { + start: normalizeTradingTimeSetting(settings.getSettingByKey('startDay'), '00:00'), + end: normalizeTradingTimeSetting(settings.getSettingByKey('endDay'), '23:59'), + closePriceEnabled: settings.isSettingTruthy('close_price'), + } +} + +export function getEffectiveTradingHours(now = new Date()): EffectiveTradingHours { void productTradingHoursVersion.value const settings = getStoreTradingHours() + const weekly = parseWeeklySchedule(useSettingsStore().getSettingByKey('trading_hours_by_day')) + + if (settings.closePriceEnabled && weekly?.mode === 'per_day') { + const day = weekly.days[weekdayKey(now)] + if (day) { + return { + start: day.start, + end: day.end, + enforced: true, + dayEnabled: day.enabled, + } + } + } + if (settings.closePriceEnabled) { return { start: settings.start, end: settings.end, enforced: true, + dayEnabled: true, } } @@ -112,6 +181,7 @@ export function getEffectiveTradingHours(): EffectiveTradingHours { start: productTradingHours.value.start, end: productTradingHours.value.end, enforced: true, + dayEnabled: true, } } @@ -119,20 +189,40 @@ export function getEffectiveTradingHours(): EffectiveTradingHours { start: settings.start, end: settings.end, enforced: false, + dayEnabled: true, } } -export function isStoreClosedBySchedule(now = new Date()): boolean { - const { start, end, enforced } = getEffectiveTradingHours() - if (!enforced) { +/** خارج از ساعت کاری سراسری — فقط وقتی close_price روشن باشد (صفحه اصلی) */ +export function isOutsideGlobalStoreHours(now = new Date()): boolean { + const settings = getStoreTradingHours() + if (!settings.closePriceEnabled) { return false } - const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() - const openSeconds = parseTimeToSeconds(start) - const closeSeconds = parseTimeToSeconds(end) + const weekly = parseWeeklySchedule(useSettingsStore().getSettingByKey('trading_hours_by_day')) + if (weekly?.mode === 'per_day') { + const day = weekly.days[weekdayKey(now)] + if (day && !day.enabled) { + return true + } + if (day) { + return isOutsideTimeWindow(now, day.start, day.end) + } + } - return currentSeconds < openSeconds || currentSeconds > closeSeconds + return isOutsideTimeWindow(now, settings.start, settings.end) +} + +export function isStoreClosedBySchedule(now = new Date()): boolean { + const { start, end, enforced, dayEnabled } = getEffectiveTradingHours(now) + if (!enforced) { + return false + } + if (!dayEnabled) { + return true + } + return isOutsideTimeWindow(now, start, end) } export function isStoreClosed(): boolean { @@ -167,35 +257,45 @@ export type PriceDisplayState = 'open' | 'closed' | 'no_quote' export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست' +/** سوییچ: خارج از ساعت کاری هم قیمت صفحه اصلی نشان داده شود */ +export function shouldShowHomePriceWhenClosed(): boolean { + return useSettingsStore().isSettingTruthy('show_home_price_when_closed') +} + +export function shouldShowLastSavedPrice(): boolean { + return useSettingsStore().isSettingTruthy('show_last_price') +} + +function hasShowableHomePrice(p = lastPrice.value): boolean { + if (!p) return false + if (Number(p.buy) > 0 || Number(p.sell) > 0 || Number(p.price_last) > 0 || Number(p.price) > 0) { + return true + } + if (shouldShowLastSavedPrice() && Number((p as { last_historical?: number }).last_historical ?? 0) > 0) { + return true + } + return false +} + /** - * وضعیت نمایش قیمت در صفحه اصلی: - * - closed → فروشگاه بسته است (خارج از ساعت کاری close_price یا بازه محصول) - * - no_quote → مظنه در دسترس نیست (قیمت زنده نداریم و show_last_price خاموش است) - * - open → نمایش عادی قیمت - * - * اولویت با status ارسالی از سرور است؛ اگر نبود به منطق سمت کلاینت برمی‌گردیم. + * وضعیت نمایش قیمت در صفحه اصلی — همان منطق بک‌اند: + * close_price خاموش / داخل ساعت → قیمت هست open، وگرنه no_quote + * close_price روشن و خارج ساعت → show_home_price_when_closed روشن: آخرین قیمت؛ خاموش: closed */ export function getPriceDisplayState(): PriceDisplayState { void productTradingHoursVersion.value + void useSettingsStore().settingsRevision const p = lastPrice.value - const status = p?.status + const hasPrice = hasShowableHomePrice(p) - if (status === 'closed') { - return 'closed' - } - if (status === 'no_quote') { - return getTradeBuyPrice(p) > 0 ? 'open' : 'no_quote' - } - if (status === 'open') { - return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0 ? 'open' : 'no_quote' - } - - // سرور status نفرستاده (سازگاری با نسخه قدیمی) → استنتاج سمت کلاینت - if (isStoreClosed()) { + if (isOutsideGlobalStoreHours()) { + if (shouldShowHomePriceWhenClosed()) { + return hasPrice ? 'open' : 'no_quote' + } return 'closed' } - return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0 ? 'open' : 'no_quote' + return hasPrice ? 'open' : 'no_quote' } export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {