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
+3 -2
View File
@@ -3,7 +3,7 @@ import { useWebSocket } from '@vueuse/core'
import { useUserStore } from '@/store/userStore'
import { useSettingsStore } from '@/store/settingsStore'
import { startChat } from '@/api/chat'
import { createUuid } from '@/utils/index'
import { createUuid, getToken } from '@/utils/index'
/** true/1/yes/activate */
export function isSettingFlagOn(value: unknown): boolean {
@@ -11,8 +11,9 @@ export function isSettingFlagOn(value: unknown): boolean {
return v === 'true' || v === '1' || v === 'yes' || v === 'activate'
}
/** نمایش چت برای کاربر نهایی: سوییچ دیپلوی + سوییچ مشتری */
/** نمایش چت برای کاربر نهایی: لاگین + سوییچ دیپلوی + سوییچ مشتری */
export function isUserSupportChatEnabled(): boolean {
if (!getToken()) return false
const settings = useSettingsStore()
return (
isSettingFlagOn(settings.getSettingByKey('show_to_users_chat')) &&
+19 -4
View File
@@ -5,6 +5,7 @@ import { useUserStore } from '@/store/userStore'
export interface DepositGuideVars {
accountHolder?: string | null
cardNumber?: string | null
accountNumber?: string | null
sheba?: string | null
/** نام مشتری واردشده — برای پیام پشتیبانی واریز */
customerName?: string | null
@@ -19,6 +20,7 @@ export const DEFAULT_DEPOSIT_SUPPORT_MESSAGE_TEXT =
/** الگوی متغیر نام — ٪نام / %نام / % نام / %نام% */
const NAME_PLACEHOLDER_RE = /[%٪]\s*نام(?:\s*[%٪])?/gu
const CARD_PLACEHOLDER_RE = /[%٪]\s*کارت(?:\s*[%٪])?/gu
const ACCOUNT_PLACEHOLDER_RE = /[%٪]\s*حساب(?:\s*[%٪])?/gu
const SHEBA_PLACEHOLDER_RE = /[%٪]\s*شبا(?:\s*[%٪])?/gu
export function getCustomerNameFromUser(): string {
@@ -45,17 +47,19 @@ export function renderDepositSupportMessageText(
return template.replace(NAME_PLACEHOLDER_RE, name)
}
/** جایگزینی متغیرها در متن راهنمای واریز — ٪نام / %نام، ٪کارت / %کارت، ٪شبا / %شبا */
/** جایگزینی متغیرها در متن راهنمای واریز — ٪نام / %نام، ٪کارت / %کارت، ٪حساب / %حساب، ٪شبا / %شبا */
export function renderDepositGuideText(
template: string,
vars: DepositGuideVars,
): string {
const accountHolder = String(vars.accountHolder ?? '').trim()
const cardNumber = String(vars.cardNumber ?? '').trim()
const accountNumber = String(vars.accountNumber ?? '').trim()
const sheba = String(vars.sheba ?? '').trim()
return template
.replace(SHEBA_PLACEHOLDER_RE, sheba)
.replace(ACCOUNT_PLACEHOLDER_RE, accountNumber)
.replace(CARD_PLACEHOLDER_RE, cardNumber)
.replace(NAME_PLACEHOLDER_RE, accountHolder)
}
@@ -67,6 +71,7 @@ export function getDepositGuideVarsFromSettings(): DepositGuideVars {
return {
accountHolder: store.getSettingByKey('deposit_account_holder'),
cardNumber: store.getSettingByKey('deposit_card_number'),
accountNumber: store.getSettingByKey('deposit_account_number'),
sheba: store.getSettingByKey('deposit_sheba'),
customerName,
}
@@ -115,6 +120,12 @@ export function formatDepositCardNumber(value?: string | null) {
return digits.replace(/(\d{4})(?=\d)/g, '$1 ').trim()
}
export function formatDepositAccountNumber(value?: string | null) {
const digits = String(value ?? '').replace(/\D/g, '')
if (!digits) return String(value ?? '').trim()
return digits
}
export function formatDepositSheba(value?: string | null) {
const normalized = String(value ?? '').replace(/\s/g, '').toUpperCase()
if (!normalized) return ''
@@ -134,6 +145,10 @@ export function displayDepositCardNumber(value?: string | null) {
return formatDepositCardNumber(value) || '**** **** **** ****'
}
export function displayDepositAccountNumber(value?: string | null) {
return formatDepositAccountNumber(value) || '—'
}
export function displayDepositSheba(value?: string | null) {
return formatDepositSheba(value) || 'IR** **** **** **** **** **** **'
}
@@ -149,7 +164,7 @@ export function getDepositSettingLabel(key: string, fallback: string) {
}
}
function normalizeDepositCopyValue(value: string, kind: 'card' | 'sheba') {
function normalizeDepositCopyValue(value: string, kind: 'card' | 'account' | 'sheba') {
const normalized = value.replace(/\s/g, '')
if (!normalized) return ''
@@ -160,12 +175,12 @@ function normalizeDepositCopyValue(value: string, kind: 'card' | 'sheba') {
return digits ? `IR${digits}` : upper
}
return normalized.replace(/\D/g, '')
return normalized.replace(/\D/g, '') || normalized
}
export async function copyDepositFieldValue(
value: string | null | undefined,
kind: 'card' | 'sheba',
kind: 'card' | 'account' | 'sheba',
emptyMessage: string,
) {
const raw = String(value ?? '').trim()
+65 -10
View File
@@ -8,28 +8,83 @@ export interface ProductFile {
full_src?: string
}
function projectBase(): string {
return resolveProjectBase().replace(/\/$/, '')
}
function storageRelativePath(value: string): string | null {
const match = value.match(/(?:^|\/)(storage\/.+)$/i)
return match ? match[1].replace(/^\/+/, '') : null
}
function joinProjectUrl(relative: string): string {
const path = relative.replace(/^\//, '')
let base = projectBase()
if (!base && typeof window !== 'undefined') {
base = `${window.location.origin}/server`
}
if (!base) {
return `/server/${path}`
}
base = base.replace(/\/$/, '')
if (path.startsWith('storage/') && !/\/(server|api)(\/|$)/i.test(base)) {
base = `${base}/server`
}
let url = `${base}/${path}`
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
url = url.replace(/^http:\/\//i, 'https://')
}
return url
}
/**
* URL فایل روی API — مسیرهای `storage/` و آدرس‌های localhost/http
* را به `node_server` می‌چسباند تا روی اندروید (PWA/TWA) لود شوند.
*/
export function normalizePublicFileUrl(raw?: string | null): string | null {
if (raw == null) return null
const trimmed = String(raw).trim()
if (!trimmed || trimmed.toLowerCase() === 'null' || trimmed.toLowerCase() === 'undefined') {
return null
}
if (/^(data:|blob:)/i.test(trimmed)) return trimmed
const storagePath = storageRelativePath(trimmed)
if (storagePath) {
return joinProjectUrl(storagePath)
}
if (/^https?:\/\//i.test(trimmed)) {
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
return trimmed.replace(/^http:\/\//i, 'https://')
}
return trimmed
}
return joinProjectUrl(trimmed)
}
export function getFileUrl(file?: ProductFile | null): string | null {
if (!file) return null
for (const value of [file.full_src, file.url, file.src]) {
if (!value) continue
if (/^https?:\/\//i.test(value)) return value
const normalized = normalizePublicFileUrl(value)
if (normalized) return normalized
}
const relative = file.src || file.full_src
if (!relative) return null
const base = resolveProjectBase().replace(/\/$/, '')
if (!base) return relative.startsWith('/') ? relative : `/${relative}`
const path = String(relative).replace(/^\//, '')
return `${base}/${path}`
return null
}
/** Resolve profile avatar URL from GET /customer/me payload */
export function getUserAvatarUrl(user?: User | null): string | null {
if (!user) return null
if (typeof user.avatar_url === 'string' && user.avatar_url) return user.avatar_url
if (typeof user.full_src === 'string' && user.full_src) return user.full_src
if (typeof user.avatar_url === 'string' && user.avatar_url) {
return normalizePublicFileUrl(user.avatar_url)
}
if (typeof user.full_src === 'string' && user.full_src) {
return normalizePublicFileUrl(user.full_src)
}
const nested = user.avatar || user.file
return getFileUrl(nested as ProductFile | undefined)
}
+37
View File
@@ -0,0 +1,37 @@
/** Lottieهای لوکال (`public/lottie`) — پیش‌دانلود تا مودال/لیست خالی نماند */
const LOTTIE_FILES = {
logoutConfirm: 'lottie/logoutConfirm.json',
emptyList: 'lottie/emptyList.json',
goldBars: 'lottie/goldBars.json',
} as const
export type LottieAssetKey = keyof typeof LOTTIE_FILES
const cache = new Map<LottieAssetKey, string>()
export function lottieSrc(key: LottieAssetKey): string {
const base = import.meta.env.BASE_URL || '/'
const path = `${base}${LOTTIE_FILES[key]}`
return path.replace(/([^:]\/)\/+/g, '$1')
}
export function getLottieData(key: LottieAssetKey): string | undefined {
return cache.get(key)
}
export async function preloadLottieAssets(): Promise<void> {
const keys = Object.keys(LOTTIE_FILES) as LottieAssetKey[]
await Promise.all(
keys.map(async (key) => {
if (cache.has(key)) return
try {
const res = await fetch(lottieSrc(key))
if (!res.ok) return
cache.set(key, await res.text())
} catch {
/* شبکه/آفلاین — بعداً از src لوکال می‌خواند */
}
}),
)
}
+176
View File
@@ -0,0 +1,176 @@
const MOBILE_MEDIA = '(max-width: 767.98px)'
const KEYBOARD_GAP_PX = 16
const SCROLL_RETRY_MS = [0, 90, 220, 420, 640]
let started = false
let activeField: HTMLElement | null = null
let blurTimer = 0
function isMobileLayout(): boolean {
return window.matchMedia(MOBILE_MEDIA).matches
}
function getKeyboardInset(): number {
const vv = window.visualViewport
if (!vv) return 0
return Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
}
function getVisibleViewportBottom(): number {
const vv = window.visualViewport
return vv ? vv.offsetTop + vv.height : window.innerHeight
}
function syncViewportVars(hasFocusedField: boolean) {
const vv = window.visualViewport
const inset = getKeyboardInset()
document.documentElement.style.setProperty('--app-keyboard-inset', `${inset}px`)
if (vv) {
document.documentElement.style.setProperty('--app-visual-viewport-height', `${Math.round(vv.height)}px`)
document.documentElement.style.setProperty('--app-visual-viewport-offset-top', `${Math.round(vv.offsetTop)}px`)
}
if (hasFocusedField && inset > 0) {
document.documentElement.dataset.keyboardOpen = 'true'
return
}
if (!hasFocusedField) {
delete document.documentElement.dataset.keyboardOpen
document.documentElement.style.setProperty('--app-keyboard-inset', '0px')
}
}
function resolveEditableTarget(node: EventTarget | null): HTMLElement | null {
if (!(node instanceof HTMLElement)) return null
const field = node.closest('input, textarea, select, [contenteditable="true"]')
if (!(field instanceof HTMLElement)) return null
if (field instanceof HTMLInputElement) {
if (field.disabled || field.readOnly || field.type === 'hidden') return null
}
if (field instanceof HTMLTextAreaElement) {
if (field.disabled || field.readOnly) return null
}
if (field instanceof HTMLSelectElement && field.disabled) return null
return field
}
function findScrollableAncestor(el: HTMLElement): HTMLElement | null {
let node: HTMLElement | null = el.parentElement
while (node && node !== document.body && node !== document.documentElement) {
const style = window.getComputedStyle(node)
const overflowY = style.overflowY
const scrollable = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
if (scrollable && node.scrollHeight > node.clientHeight + 1) {
return node
}
node = node.parentElement
}
return null
}
function ensureFieldVisible(field: HTMLElement) {
if (!isMobileLayout()) return
syncViewportVars(true)
const rect = field.getBoundingClientRect()
const visibleBottom = getVisibleViewportBottom() - KEYBOARD_GAP_PX
const scrollParent = findScrollableAncestor(field)
if (rect.bottom > visibleBottom) {
const delta = rect.bottom - visibleBottom
if (scrollParent) {
scrollParent.scrollTop += delta
} else {
window.scrollBy({ top: delta, left: 0, behavior: 'auto' })
}
}
if (rect.top < KEYBOARD_GAP_PX && scrollParent) {
scrollParent.scrollTop += rect.top - KEYBOARD_GAP_PX
}
field.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' })
}
function scheduleEnsureVisible(field: HTMLElement) {
SCROLL_RETRY_MS.forEach((delay) => {
window.setTimeout(() => {
const active = resolveEditableTarget(document.activeElement)
if (!active || active !== field) return
ensureFieldVisible(field)
}, delay)
})
}
function onFocusIn(event: FocusEvent) {
if (!isMobileLayout()) return
const field = resolveEditableTarget(event.target)
if (!field) return
activeField = field
window.clearTimeout(blurTimer)
scheduleEnsureVisible(field)
}
function onFocusOut() {
window.clearTimeout(blurTimer)
blurTimer = window.setTimeout(() => {
const next = resolveEditableTarget(document.activeElement)
activeField = next
if (!next) {
syncViewportVars(false)
return
}
syncViewportVars(true)
scheduleEnsureVisible(next)
}, 120)
}
function onViewportChange() {
if (!isMobileLayout()) return
syncViewportVars(Boolean(activeField))
const active = resolveEditableTarget(document.activeElement) ?? activeField
if (!active) return
activeField = active
ensureFieldVisible(active)
}
/** موبایل: با باز شدن کیبورد، فیلد فوکوس‌شده بالای کیبورد دیده شود. */
export function initMobileInputKeyboardScroll() {
if (typeof window === 'undefined' || started) return
started = true
document.addEventListener('focusin', onFocusIn, true)
document.addEventListener('focusout', onFocusOut, true)
window.visualViewport?.addEventListener('resize', onViewportChange, { passive: true })
window.visualViewport?.addEventListener('scroll', onViewportChange, { passive: true })
if (import.meta.hot) {
import.meta.hot.dispose(() => {
started = false
document.removeEventListener('focusin', onFocusIn, true)
document.removeEventListener('focusout', onFocusOut, true)
window.visualViewport?.removeEventListener('resize', onViewportChange)
window.visualViewport?.removeEventListener('scroll', onViewportChange)
})
}
}
+57 -7
View File
@@ -13,18 +13,68 @@ export function persianToEnglishDigits(value: string | number | null | undefined
.replace(/٫/g, '.')
}
export function shouldNormalizeInputDigits(options: {
/**
* اعداد فارسی/عربی + جداکننده‌های اعشار رایج ماشین‌حساب فارسی.
* - `slashAsDecimal` پیش‌فرض true: `/` → `.`
* - `commaAsThousand` true (مبلغ): `,` / `،` حذف می‌شوند
* - `commaAsThousand` false (وزن/اعشار): `,` / `،` هم → `.`
*/
export function normalizeDecimalInput(
val: string | number | null | undefined,
options?: { commaAsThousand?: boolean }
): string {
if (val == null || val === '') return ''
const commaAsThousand = options?.commaAsThousand ?? true
let normalized = persianToEnglishDigits(String(val))
if (commaAsThousand) {
normalized = normalized.replace(/[,،]/g, '')
} else {
normalized = normalized.replace(/[,،]/g, '.')
}
normalized = normalized
.replace(/\//g, '.')
.replace(/[^\d.]/g, '')
const firstDot = normalized.indexOf('.')
if (firstDot !== -1) {
normalized =
normalized.slice(0, firstDot + 1) +
normalized.slice(firstDot + 1).replace(/\./g, '')
}
return normalized
}
export function parseDecimalInput(
val: string | number | null | undefined,
options?: { commaAsThousand?: boolean }
): number {
const normalized = normalizeDecimalInput(val, options)
if (!normalized) return NaN
return Number(normalized)
}
export function shouldNormalizeInputDigits(_options?: {
isPassword?: boolean
type?: string
inputmode?: string
}): boolean {
if (options.isPassword) {
return true
}
// همیشه ارقام فارسی/عربی → انگلیسی (لاگین، گرم، تعداد، مظنه، …)
return true
}
/** فیلدهای وزن/مبلغ/عدد اعشاری که باید `/` را ممیز بگیرند */
export function shouldNormalizeDecimalSeparators(options: {
isPassword?: boolean
type?: string
inputmode?: string
}): boolean {
if (options.isPassword) return false
const type = String(options.type ?? '').trim().toLowerCase()
if (type === 'price' || type === 'number' || type === 'tel') {
return true
}
if (type === 'price' || type === 'number') return true
const mode = String(options.inputmode ?? '').trim().toLowerCase()
return mode === 'numeric' || mode === 'decimal' || mode === 'number'
}
+100 -3
View File
@@ -7,6 +7,8 @@ import {
priceWssEnabled,
shouldUsePricePolling,
} from '@/utils/priceSocketState'
import { applyBoardSpecialToLastPayload } from '@/utils/specialPricing'
import { useSettingsStore, type AppSetting } from '@/store/settingsStore'
const LAST_INTERVAL_MS = 10000
@@ -16,10 +18,21 @@ let lastSubscribers = 0
/** @deprecated از lastPrice استفاده کنید — برای سازگاری با کد قدیمی sync می‌شود */
export const price = ref<Price>()
export const lastPrice = ref<Price>()
/** حداقل یک بار تلاش برای گرفتن مظنه تمام شده (موفق یا ناموفق) */
export const lastPriceReady = ref(false)
/**
* بعد از اولین پاسخ خالی/no_quote، چند لحظه صبر می‌کنیم تا تیک SSE/زنده برسد
* و «مظنه در دسترس نیست» زودهنگام نشان داده نشود.
*/
export const lastPriceSettleGeneration = ref(0)
export const olderPrice = ref<number | undefined>(0)
const QUOTE_ABSENCE_SETTLE_MS = 2500
let lastPriceSourceAt = 0
let consecutivePriceFetchFailures = 0
let lastPriceReadyAt = 0
let quoteSettleTimer: ReturnType<typeof setTimeout> | undefined
function hasValidPricePayload(data?: Price | null): boolean {
if (!data) {
@@ -33,13 +46,77 @@ 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[])
}
function clearQuoteSettleTimer() {
if (quoteSettleTimer) {
clearTimeout(quoteSettleTimer)
quoteSettleTimer = undefined
}
}
function bumpQuoteSettle() {
lastPriceSettleGeneration.value += 1
}
function scheduleQuoteAbsenceSettle() {
clearQuoteSettleTimer()
if (hasValidPricePayload(lastPrice.value) || lastPrice.value?.status === 'closed') {
bumpQuoteSettle()
return
}
const elapsed = lastPriceReadyAt > 0 ? Date.now() - lastPriceReadyAt : 0
const wait = Math.max(0, QUOTE_ABSENCE_SETTLE_MS - elapsed)
quoteSettleTimer = setTimeout(() => {
quoteSettleTimer = undefined
bumpQuoteSettle()
}, wait)
}
function markLastPriceReady() {
if (!lastPriceReady.value) {
lastPriceReadyAt = Date.now()
}
lastPriceReady.value = true
scheduleQuoteAbsenceSettle()
}
/** قیمت آمده، بسته قطعی است، یا پنجرهٔ انتظارِ مظنه خالی تمام شده */
export function isLastPriceAbsenceSettled(): boolean {
void lastPriceSettleGeneration.value
if (!lastPriceReady.value) return false
if (hasValidPricePayload(lastPrice.value)) return true
if (lastPrice.value?.status === 'closed') return true
if (lastPriceReadyAt <= 0) return false
return Date.now() - lastPriceReadyAt >= QUOTE_ABSENCE_SETTLE_MS
}
export function markPriceSourceReceived() {
lastPriceSourceAt = Date.now()
consecutivePriceFetchFailures = 0
void import('@/utils/priceSocket').then(({ clearPriceNetworkAlert }) => {
clearPriceNetworkAlert()
})
}
export function markPriceSourceFetchFailed() {
consecutivePriceFetchFailures += 1
if (consecutivePriceFetchFailures >= 2) {
void import('@/utils/priceSocket').then(({ notifyPriceLastFetchFailed }) => {
notifyPriceLastFetchFailed()
})
}
}
export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean {
@@ -163,18 +240,38 @@ export async function getLastPrices(): Promise<boolean> {
console.log(e)
markPriceSourceFetchFailed()
return false
} finally {
markLastPriceReady()
}
}
export function applyLastPricePayload(dataLast: Price) {
const normalized = normalizePriceResponse(dataLast)
if (lastPrice.value?.price_last != normalized.price_last) {
applyTradingHoursSettingsPatch(dataLast)
const normalized = normalizePriceResponse(
applyBoardSpecialToLastPayload(dataLast as Record<string, unknown>) as Price,
)
const nextBuy = getTradeBuyPrice(normalized)
const prevBuy = getTradeBuyPrice(lastPrice.value)
const nextValid = hasValidPricePayload(normalized)
const prevValid = hasValidPricePayload(lastPrice.value)
// قطع فید / no_quote خالی — قیمت فعلی را پاک نکن؛ فقط closed قطعی اجازهٔ صفر شدن دارد
if (!nextValid && prevValid && normalized.status !== 'closed') {
markLastPriceReady()
return
}
if (prevBuy > 0 && nextBuy > 0 && prevBuy !== nextBuy) {
olderPrice.value = prevBuy
} else if (lastPrice.value?.price_last != normalized.price_last) {
olderPrice.value = lastPrice.value?.price_last
}
lastPrice.value = normalized
markLastPriceReady()
price.value = {
...normalized,
buy: getTradeBuyPrice(normalized),
buy: nextBuy,
sell: getTradeSellPrice(normalized),
}
+190 -40
View File
@@ -9,6 +9,10 @@ import {
priceStreamLifecycle,
priceWssEnabled,
} from '@/utils/priceSocketState'
import {
applyBoardSpecialToLastPayload,
setSpecialPricingPayload,
} from '@/utils/specialPricing'
export {
priceSocketConnected,
@@ -20,7 +24,8 @@ export {
const RECONNECT_BASE_MS = 3000
const RECONNECT_MAX_MS = 30000
const FAILURES_BEFORE_ALERT = 4
/** چند بار خطای پشت‌سرهم /api/price/last قبل از نمایش مدال */
const LAST_FETCH_FAILURES_BEFORE_ALERT = 2
const ALERT_COOLDOWN_MS = 5 * 60 * 1000
const FALLBACK_POLL_INTERVAL_MS = 10000
/** بدون قیمت معتبر از مرجع (SSE یا API) */
@@ -29,8 +34,95 @@ const PRICE_SOURCE_STALE_MS_DESKTOP = 35000
/** بدون event قیمت؛ ping هر ۵ث از سرور activity را تازه می‌کند */
const STALE_STREAM_MS = 120000
const FALLBACK_POLL_DELAY_MS = 2500
const STREAM_CONNECTING = 0
const STREAM_OPEN = 1
const STREAM_CLOSED = 2
let eventSource: EventSource | null = null
/** EventSource نمی‌تواند Authorization بفرستد؛ استریم همچنان auth:customer می‌خواهد. */
class AuthPriceStream extends EventTarget {
readyState = STREAM_CONNECTING
onopen: (() => void) | null = null
onerror: (() => void) | null = null
onmessage: (() => void) | null = null
private abort = new AbortController()
constructor(url: string, token: string) {
super()
void this.start(url, token)
}
close() {
this.readyState = STREAM_CLOSED
this.abort.abort()
}
private async start(url: string, token: string) {
try {
const response = await fetch(url, {
headers: {
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
cache: 'no-store',
signal: this.abort.signal,
})
if (!response.ok || !response.body) {
this.readyState = STREAM_CLOSED
this.onerror?.()
return
}
this.readyState = STREAM_OPEN
this.onopen?.()
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushSse(buffer)
}
this.readyState = STREAM_CLOSED
this.onerror?.()
} catch {
if (this.abort.signal.aborted) {
return
}
this.readyState = STREAM_CLOSED
this.onerror?.()
}
}
private flushSse(buffer: string): string {
const blocks = buffer.split('\n\n')
const rest = blocks.pop() ?? ''
for (const block of blocks) {
let event = 'message'
const data: string[] = []
for (const line of block.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
}
this.dispatchEvent(new MessageEvent(event, { data: data.join('\n') }))
this.onmessage?.()
}
return rest
}
}
let eventSource: AuthPriceStream | null = null
let subscribers = 0
let stopped = false
let getSetting: ((key: string) => unknown) | null = null
@@ -99,16 +191,12 @@ function resolveStreamUrl(): string | null {
return null
}
const token = findStoredAuthToken()
if (!token) {
if (!findStoredAuthToken()) {
return null
}
const base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '')
const url = new URL(`${base}${resolveStreamPath()}`)
url.searchParams.set('token', token)
return url.toString()
return `${base}${resolveStreamPath()}`
}
function clearReconnectTimer() {
@@ -137,7 +225,7 @@ function startStaleCheck() {
stopped
|| subscribers <= 0
|| !eventSource
|| eventSource.readyState !== EventSource.OPEN
|| eventSource.readyState !== STREAM_OPEN
) {
return
}
@@ -190,6 +278,16 @@ function scheduleFallbackPoll() {
}, FALLBACK_POLL_DELAY_MS)
}
export function clearPriceNetworkAlert() {
networkAlertOpen = false
pendingPriceAlert = false
}
/** وقتی /api/price/last خطا می‌دهد (از polling عادی یا fallback) */
export function notifyPriceLastFetchFailed() {
void maybeShowNetworkAlert()
}
async function runFallbackPoll() {
if (!priceWssEnabled.value || priceSocketConnected.value) {
return
@@ -213,8 +311,9 @@ async function runFallbackPoll() {
&& priceResult.value === true
&& hasRecentPriceFromSource(getPriceSourceStaleMs())
if (!priceOk) {
markPriceSourceFetchFailed()
if (priceOk) {
clearPriceNetworkAlert()
return
}
void maybeShowNetworkAlert()
@@ -243,25 +342,7 @@ async function isRealPriceOutage(): Promise<boolean> {
return false
}
const fetchFailures = consecutivePriceSourceFailures()
if (fetchFailures >= 2) {
return true
}
if (!priceSocketConnected.value && fetchFailures >= 1 && reconnectAttempt >= 2) {
return true
}
if (!isMobileDevice() && !navigator.onLine && fetchFailures >= 1) {
return true
}
if (!isMobileDevice() && reconnectAttempt >= FAILURES_BEFORE_ALERT) {
return true
}
return false
return consecutivePriceSourceFailures() >= LAST_FETCH_FAILURES_BEFORE_ALERT
}
function showNetworkAlert() {
@@ -322,7 +403,6 @@ function scheduleReconnect() {
reconnectTimer = setTimeout(() => {
reconnectAttempt += 1
connect()
maybeShowNetworkAlert()
}, delay)
}
@@ -331,13 +411,61 @@ 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 = 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 hasDisplayable =
Number(last.buy ?? 0) > 0
|| Number(last.sell ?? 0) > 0
|| Number(last.price_last ?? 0) > 0
|| Number(last.price ?? 0) > 0
const hasStatus = last.status != null || typeof last.closed === 'boolean'
if (!hasDisplayable && !hasStatus) {
if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) {
touchStreamActivity()
void applySettingsFromStream(raw.settings_patch)
}
return
}
// SSE گاهی last خالی/no_quote می‌فرستد؛ قیمت معتبر فعلی را پاک نکن
if (!hasDisplayable) {
if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) {
touchStreamActivity()
void applySettingsFromStream(raw.settings_patch)
}
void import('@/utils/price').then(({ getTradeBuyPrice, lastPrice }) => {
const keepCurrent =
getTradeBuyPrice(lastPrice.value) > 0 && last.status !== 'closed'
touchStreamActivity()
if (keepCurrent) return
void applyLastFromStream(last)
})
return
}
@@ -345,6 +473,11 @@ function handleLastPayload(payload: unknown) {
void applyLastFromStream(last)
}
function handleSettingsPayload(payload: unknown) {
touchStreamActivity()
void applySettingsFromStream(payload)
}
async function applyListFromStream(products: Product[]) {
const { applyProductsListFromSocket } = await import(
'@/views/base/products/composables/useProductsPageData'
@@ -411,7 +544,7 @@ async function applyUnreadCountFromStreamPayload(payload: Record<string, unknown
}
}
function bindStreamEvents(source: EventSource) {
function bindStreamEvents(source: AuthPriceStream) {
source.addEventListener('last', (event) => {
try {
handleLastPayload(JSON.parse(event.data))
@@ -420,6 +553,14 @@ function bindStreamEvents(source: EventSource) {
}
})
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))
@@ -452,6 +593,14 @@ function bindStreamEvents(source: EventSource) {
}
})
source.addEventListener('special-pricing', (event) => {
try {
setSpecialPricingPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Special-pricing SSE parse failed', error)
}
})
source.addEventListener('ping', () => {
touchStreamActivity()
})
@@ -493,7 +642,8 @@ function connect() {
}
const url = resolveStreamUrl()
if (!url) {
const token = findStoredAuthToken()
if (!url || !token) {
priceSocketConnected.value = false
priceStreamLifecycle.value = 'closed'
clearConnectingLifecycle()
@@ -502,7 +652,7 @@ function connect() {
if (eventSource) {
const state = eventSource.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
return
}
disconnectSourceOnly()
@@ -511,7 +661,7 @@ function connect() {
markConnectingLifecycle()
try {
eventSource = new EventSource(url)
eventSource = new AuthPriceStream(url, token)
} catch (error) {
console.warn('Price SSE connect failed', error)
handleStreamLost()
@@ -534,7 +684,7 @@ function connect() {
eventSource.onerror = () => {
const state = eventSource?.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
return
}
@@ -648,7 +798,7 @@ function onVisibilityChange() {
void maybeShowNetworkAlert()
}
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
if (!eventSource || eventSource.readyState !== STREAM_OPEN) {
forceReconnect()
}
}
@@ -658,7 +808,7 @@ function onPageShow(event: PageTransitionEvent) {
return
}
if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) {
if (event.persisted || !eventSource || eventSource.readyState !== STREAM_OPEN) {
reconnectAttempt = 0
networkAlertOpen = false
forceReconnect()
+14 -2
View File
@@ -648,19 +648,31 @@ export function enrichInventoryWithCatalog<T extends { id?: number }>(
})
}
function withProductImageCacheBust(
url: string,
product?: { file_id?: number | string | null; file?: ProductFile | null } | null,
): string {
if (!url || !/^https?:\/\//i.test(url)) return url
const version = product?.file_id ?? product?.file?.id
if (version == null || version === '' || Number(version) <= 0) return url
if (/[?&]v=/.test(url)) return url
return `${url}${url.includes('?') ? '&' : '?'}v=${encodeURIComponent(String(version))}`
}
export function getProductDisplayImage(product?: {
group_type?: string | null
title?: string | null
file?: ProductFile | null
file_id?: number | string | null
img?: string | null
} | null): string {
const fileUrl = getFileUrl(product?.file)
if (fileUrl) {
return fileUrl
return withProductImageCacheBust(fileUrl, product)
}
if (product?.img && /^https?:\/\//i.test(product.img)) {
return product.img
return withProductImageCacheBust(product.img, product)
}
const group = normalizeProductGroupType(product?.group_type)
+6 -2
View File
@@ -43,19 +43,23 @@ export function resolveProductCashFeeRial(
}
export function resolveProductCashFeeBuyRial(product: ProductCashFeeSource, basePriceRial: number) {
return resolveProductCashFeeRial(
const baseFee = resolveProductCashFeeRial(
product.cash_fee_buy,
product.cash_fee_buy_is_percent,
basePriceRial,
)
const special = Number((product as { special_profit_buy?: number }).special_profit_buy ?? 0)
return baseFee + (Number.isFinite(special) ? Math.round(special) : 0)
}
export function resolveProductCashFeeSellRial(product: ProductCashFeeSource, basePriceRial: number) {
return resolveProductCashFeeRial(
const baseFee = resolveProductCashFeeRial(
product.cash_fee_sell,
product.cash_fee_sell_is_percent,
basePriceRial,
)
const special = Number((product as { special_profit_sell?: number }).special_profit_sell ?? 0)
return baseFee + (Number.isFinite(special) ? Math.round(special) : 0)
}
export function formatProductCashFeeLabel(
+6
View File
@@ -59,6 +59,7 @@ export interface ProductUnitSource {
group_type?: string | null
amount?: number | null
trade_by_quantity?: boolean | number | null
price_formula_enabled?: boolean | number | null
}
export function isWeightBasedProductUnit(unit?: string | null) {
@@ -80,6 +81,11 @@ export function hasProductUnitWeight(product?: ProductUnitSource | null) {
/** ضریب قیمت — فقط «متغیر» بدون مرجع اتوماتیک: قیمت هر گرم × وزن */
export function productPriceAmountFactor(product?: ProductUnitSource | null) {
// قیمت فرمولی = قیمت واحد هر عدد (وزن داخل فرمول است)
if (product?.price_formula_enabled) {
return 1
}
if (product?.reading_method) {
return 1
}
+22 -9
View File
@@ -1,3 +1,22 @@
/** سقف inset بالا (Dynamic Island / notch) — فقط وقتی env صفر است و واقعاً iOS standalone */
const MAX_SAFE_TOP_PX = 59
/** فقط PWA نصب‌شدهٔ آیفون — سافاری این را نگیرد */
const IOS_INSTALLED_TOP_BUFFER_PX = 10
function isStandaloneDisplay() {
return (
window.matchMedia('(display-mode: standalone)').matches ||
Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone)
)
}
/** iPhone / iPad — شامل iPadOS که خود را Mac معرفی می‌کند */
function isAppleTouchDevice() {
const ua = window.navigator.userAgent || ''
if (/iP(hone|od|ad)/i.test(ua)) return true
return window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1
}
/**
* روی بعضی buildهای iOS PWA، env(safe-area-inset-*) داخل CSS var صفر می‌ماند
* در حالی که محتوا واقعاً زیر نوار وضعیت رسم می‌شود. این تابع مقدار واقعی را
@@ -36,16 +55,10 @@ export function syncSafeAreaInsets() {
root.style.setProperty('--app-safe-right', right)
root.style.setProperty('--app-safe-left', left)
// اگر env صفر بود ولی PWA standalone هستیم، حداقل فضای نوار وضعیت را جبران کن
const topPx = Number.parseFloat(top) || 0
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
// iOS Safari legacy
Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone)
if (isStandalone && topPx < 1) {
// Dynamic Island ≈ ۵۹px؛ بدون env حداقل این را جبران می‌کنیم
root.style.setProperty('--app-safe-top', '59px')
if (isStandaloneDisplay() && isAppleTouchDevice()) {
const next = (topPx < 1 ? MAX_SAFE_TOP_PX : topPx) + IOS_INSTALLED_TOP_BUFFER_PX
root.style.setProperty('--app-safe-top', `${next}px`)
}
}
+76
View File
@@ -0,0 +1,76 @@
const OVERLAY_ID = 'sa-server-unavailable-overlay'
export const SERVER_UNAVAILABLE_MESSAGE = 'خطا در برقراری ارتباط با سرور'
const MESSAGE = SERVER_UNAVAILABLE_MESSAGE
/** Thrown after kill-switch overlay is shown — bootstrap must not replace it with internet error */
export class ServerUnavailableError extends Error {
readonly isServerUnavailable = true
constructor(message = MESSAGE) {
super(message)
this.name = 'ServerUnavailableError'
}
}
export function isServerUnavailableError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
if (error instanceof ServerUnavailableError) return true
const record = error as { isServerUnavailable?: unknown; message?: unknown }
return (
record.isServerUnavailable === true ||
record.message === SERVER_UNAVAILABLE_MESSAGE
)
}
export function isServerUnreachableConfig(config: unknown): boolean {
if (!config || typeof config !== 'object') return false
const root = config as Record<string, unknown>
const user =
root.user && typeof root.user === 'object'
? (root.user as Record<string, unknown>)
: null
if (root.server_reachable === false) return true
if (user && user.server_reachable === false) return true
return false
}
export function showServerUnavailableOverlay(message = MESSAGE) {
if (typeof document === 'undefined') return
let root = document.getElementById(OVERLAY_ID)
if (!root) {
root = document.createElement('div')
root.id = OVERLAY_ID
document.body.appendChild(root)
}
// z-index بالاتر از boot-error تا پیام قطع عمدی با «خطای اینترنت» عوض نشود
root.innerHTML = `
<div style="position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;direction:rtl;font-family:sans-serif;padding:24px;background:#ffffff;color:#111827;">
<div style="width:min(420px,100%);text-align:center;background:#fff;border:1px solid rgba(15,23,42,0.08);border-radius:24px;padding:28px 22px;">
<div style="font-size:42px;margin-bottom:12px;">⚠️</div>
<h2 style="margin:0 0 20px;font-size:20px;">${message}</h2>
<button
type="button"
onclick="location.reload()"
style="padding:11px 22px;border:0;border-radius:12px;background:#2563eb;color:white;cursor:pointer;font-weight:700;"
>
تلاش مجدد
</button>
</div>
</div>
`
}
export function hideServerUnavailableOverlay() {
if (typeof document === 'undefined') return
const root = document.getElementById(OVERLAY_ID)
if (root) root.remove()
}
export function assertServerReachableConfig(config: unknown) {
if (isServerUnreachableConfig(config)) {
showServerUnavailableOverlay()
throw new ServerUnavailableError()
}
}
+189 -5
View File
@@ -1,7 +1,7 @@
import { ref } from 'vue'
import { getSettings } from '@/api/configuration'
import { useSettingsStore } from '@/store/settingsStore'
export const serviceFeePercentage = ref<number>(0.01)
export const serviceFeePercentage = ref<number>(0)
/** true = مبالغ رند: فروش → ریال دقیق (کارمزد از طلا)؛ خرید → وزن دقیق (کارمزد از ریال) */
export const feeDeductFromGold = ref(false)
@@ -9,6 +9,11 @@ export const feeDeductFromGold = ref(false)
/** آخرین فیلدی که کاربر مستقیم ویرایش کرده — مبنای به‌روزرسانی هنگام تغییر قیمت */
export type TradeInputLock = 'gram' | 'rial' | null
/** ریال قفل است؟ (کاربر مبلغ ریالی را مستقیم وارد کرده) → کارمزد از طلا */
export function isRialLocked(inputLock?: TradeInputLock): boolean {
return inputLock === 'rial'
}
/** حداکثر اعشار ذخیره/ارسال به سرور (مطابق orders.amount) */
export const TRADE_GRAM_STORAGE_DECIMALS = 10
@@ -44,13 +49,18 @@ export function normalizeServiceFeeRatio(value: number): number {
export async function loadServiceFeeFromSettings() {
try {
const settings = await getSettings()
if (settings && Array.isArray(settings)) {
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
const settings = settingsStore.settings
if (settings?.length) {
const feeSetting = settings.find(
(s) => s?.key === 'fee_service_charge_percentage' && s?.is_active === true
)
if (feeSetting?.value !== undefined && feeSetting?.value !== null) {
serviceFeePercentage.value = normalizeServiceFeeRatio(Number(feeSetting.value))
const raw = String(feeSetting.value).trim()
serviceFeePercentage.value = raw === '' ? 0 : normalizeServiceFeeRatio(Number(raw))
}
const feeModeSetting = settings.find(
@@ -215,3 +225,177 @@ export function calcSellGoldDebit(goldValue?: number | null, feeInGrams?: number
}
return gold
}
/** مبلغ پایه معامله قبل از اعمال کارمزد ریالی (خرید با افزودن کارمزد به ریال) */
export function calcTradeBasePriceRial(orderGoldAmount: number, unitPriceRial: number) {
return Math.round(Number(orderGoldAmount) * Number(unitPriceRial))
}
export interface TradeConfirmRow {
label: string
gram?: number | null
rial?: number | null
bold?: boolean
minus?: boolean
plus?: boolean
dividerBefore?: boolean
}
export interface TradeConfirmSection {
heading: string
rows: TradeConfirmRow[]
footnote?: string
}
export interface TradeConfirmBreakdown {
sections: TradeConfirmSection[]
formHint: string
}
function hasTradeFee(feeInGrams: number, feeInRial: number) {
return feeInGrams > 0 || feeInRial > 0
}
function grossGoldFromRial(rial: number, unitPriceRial: number) {
if (!rial || !unitPriceRial) return 0
return normalizeOrderGoldAmount(rial / unitPriceRial)
}
/**
* جزئیات مدال تأیید — دو بخش «کسر از کیف» و «واریز به کیف».
* فقط بر پایهٔ قفل ورودی تصمیم می‌گیرد:
* • ریال قفل → مبلغ دقیق، کارمزد از طلا.
* • گرم قفل → وزن دقیق، کارمزد از ریال.
*/
export function buildTradeConfirmBreakdown(params: {
isBuy: boolean
inputLock: TradeInputLock
orderGoldAmount: number
feeInGrams: number
feeInRial: number
payablePrice: number
unitPriceRial: number
sellGoldDebit?: number
}): TradeConfirmBreakdown {
const {
isBuy,
inputLock,
orderGoldAmount,
feeInGrams,
feeInRial,
payablePrice,
unitPriceRial,
sellGoldDebit,
} = params
const hasFee = hasTradeFee(feeInGrams, feeInRial)
const rialLocked = isRialLocked(inputLock)
const weight = orderGoldAmount
if (!isBuy) {
if (rialLocked) {
const debit = sellGoldDebit ?? parseFloat((weight + feeInGrams).toFixed(4))
const goldRows: TradeConfirmRow[] = hasFee
? [
{ label: 'وزن معادل مبلغ', gram: weight },
{ label: 'کارمزد', gram: feeInGrams, minus: true },
{ label: 'جمع کسر از کیف طلا', gram: debit, bold: true, dividerBefore: true },
]
: [{ label: 'وزن فروش', gram: weight, bold: true }]
return {
sections: [
{ heading: 'از کیف طلا کسر می‌شود', rows: goldRows },
{
heading: 'به کیف پول واریز می‌شود',
rows: [{ label: 'مبلغ دریافتی', rial: payablePrice, bold: true }],
footnote: hasFee
? 'دقیقاً همان مبلغی که وارد کردید واریز می‌شود؛ کارمزد فقط از طلای کیف کسر می‌شود.'
: undefined,
},
],
formHint: hasFee
? `${payablePrice.toLocaleString('fa-IR')} ریال دریافت — ${formatTradeGramDisplay(debit)} گرم از کیف کسر می‌شود (شامل ${formatTradeGramDisplay(feeInGrams)} گرم کارمزد)`
: `${payablePrice.toLocaleString('fa-IR')} ریال دریافت`,
}
}
const grossSellRial = Math.round(weight * unitPriceRial)
const cashRows: TradeConfirmRow[] = hasFee
? [
{ label: 'ارزش فروش', rial: grossSellRial },
{ label: 'کارمزد', rial: feeInRial, minus: true },
{ label: 'خالص دریافتی', rial: payablePrice, bold: true, dividerBefore: true },
]
: [{ label: 'مبلغ دریافتی', rial: payablePrice, bold: true }]
return {
sections: [
{ heading: 'از کیف طلا کسر می‌شود', rows: [{ label: 'وزن فروش', gram: weight, bold: true }] },
{
heading: 'به کیف پول واریز می‌شود',
rows: cashRows,
footnote: hasFee ? 'کارمزد از مبلغ ریالی دریافتی کم شده است.' : undefined,
},
],
formHint: hasFee
? `${formatTradeGramDisplay(weight)} گرم فروش — ${feeInRial.toLocaleString('fa-IR')} ریال کارمزد از مبلغ دریافتی کسر می‌شود`
: `${formatTradeGramDisplay(weight)} گرم فروش — ${payablePrice.toLocaleString('fa-IR')} ریال به کیف پول`,
}
}
if (rialLocked) {
const grossGram = grossGoldFromRial(payablePrice, unitPriceRial)
const feeGram = parseFloat(Math.max(0, grossGram - weight).toFixed(4))
const goldRows: TradeConfirmRow[] = hasFee
? [
{ label: 'معادل طلای خام', gram: grossGram },
{ label: 'کارمزد', gram: feeGram, minus: true },
{ label: 'خالص واریزی به کیف طلا', gram: weight, bold: true, dividerBefore: true },
]
: [{ label: 'واریزی به کیف طلا', gram: weight, bold: true }]
return {
sections: [
{
heading: 'از کیف پول کسر می‌شود',
rows: [{ label: 'مبلغ پرداختی', rial: payablePrice, bold: true }],
footnote: 'دقیقاً همان مبلغی که وارد کردید؛ کارمزد جداگانه اضافه نمی‌شود.',
},
{
heading: 'به کیف طلا واریز می‌شود',
rows: goldRows,
footnote: hasFee ? 'کارمزد از وزن طلای دریافتی کسر شده است.' : undefined,
},
],
formHint: hasFee
? `${payablePrice.toLocaleString('fa-IR')} ریال پرداخت — ${formatTradeGramDisplay(weight)} گرم واریز (کارمزد از طلا کسر شد)`
: `${payablePrice.toLocaleString('fa-IR')} ریال پرداخت`,
}
}
const basePriceRial = calcTradeBasePriceRial(weight, unitPriceRial)
const cashRows: TradeConfirmRow[] = hasFee
? [
{ label: `ارزش ${formatTradeGramDisplay(weight)} گرم`, rial: basePriceRial },
{ label: 'کارمزد', rial: feeInRial, plus: true },
{ label: 'جمع پرداختی', rial: payablePrice, bold: true, dividerBefore: true },
]
: [{ label: 'مبلغ پرداختی', rial: payablePrice, bold: true }]
return {
sections: [
{
heading: 'از کیف پول کسر می‌شود',
rows: cashRows,
footnote: hasFee ? 'کارمزد به مبلغ پرداختی اضافه شده است.' : undefined,
},
{
heading: 'به کیف طلا واریز می‌شود',
rows: [{ label: 'دقیقاً', gram: weight, bold: true }],
},
],
formHint: hasFee
? `${payablePrice.toLocaleString('fa-IR')} ریال (شامل ${feeInRial.toLocaleString('fa-IR')} ریال کارمزد) — ${formatTradeGramDisplay(weight)} گرم واریز`
: `${payablePrice.toLocaleString('fa-IR')} ریال پرداخت`,
}
}
+80
View File
@@ -0,0 +1,80 @@
import { ref } from 'vue'
export type SpecialPricingRule = {
product_id?: number
package_id: number
title: string
profit_buy: number
profit_sell: number
show_product: boolean
}
export type SpecialPricingPayload = {
enabled?: boolean
products?: SpecialPricingRule[]
board?: SpecialPricingRule | null
}
const state = ref<{
enabled: boolean
byProduct: Record<number, SpecialPricingRule>
board: SpecialPricingRule | null
}>({
enabled: false,
byProduct: {},
board: null,
})
export function setSpecialPricingPayload(payload?: SpecialPricingPayload | null) {
if (!payload || !payload.enabled) {
state.value = { enabled: false, byProduct: {}, board: null }
return
}
const byProduct: Record<number, SpecialPricingRule> = {}
for (const row of payload.products ?? []) {
const pid = Number(row.product_id ?? 0)
if (pid > 0) byProduct[pid] = row
}
state.value = {
enabled: true,
byProduct,
board: payload.board ?? null,
}
}
export function getSpecialPricingState() {
return state
}
/** اگر سرور قبلاً special_pricing نزده، اختلاف تابلو را روی last اعمال کن */
export function applyBoardSpecialToLastPayload<T extends Record<string, any>>(payload: T): T {
if (!payload || typeof payload !== 'object') return payload
if (payload.special_pricing) return payload
const board = state.value.board
if (!state.value.enabled || !board) return payload
const buyExtra = Number(board.profit_buy ?? 0)
const sellExtra = Number(board.profit_sell ?? 0)
if (!buyExtra && !sellExtra) return payload
const next = { ...payload } as T & Record<string, any>
if (Number(next.buy ?? 0) > 0 && buyExtra) {
next.buy = Math.max(0, Number(next.buy) + buyExtra)
}
if (Number(next.sell ?? 0) > 0 && sellExtra) {
next.sell = Math.max(0, Number(next.sell) - sellExtra)
}
if (Number(next.price_last ?? 0) > 0 && buyExtra) {
next.price_last = Math.max(0, Number(next.price_last) + buyExtra)
}
next.special_pricing = {
package_id: board.package_id,
title: board.title,
profit_buy: buyExtra,
profit_sell: sellExtra,
}
return next
}
+178 -50
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { useSettingsStore } from '@/store/settingsStore'
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
import { isLastPriceAbsenceSettled, lastPrice, lastPriceReady } 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<WeekdayKey, DaySchedule>
}
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<WeeklySchedule>
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<WeekdayKey, DaySchedule>
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,16 +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
}
return isOutsideStoreTradingHours(now, start, 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 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 isOutsideStoreTradingHours(
@@ -136,20 +230,14 @@ export function isOutsideStoreTradingHours(
start?: string,
end?: string,
): boolean {
const hours = start != null && end != null
? { start, end }
: getEffectiveTradingHours()
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
const openSeconds = parseTimeToSeconds(hours.start)
const closeSeconds = parseTimeToSeconds(hours.end)
return currentSeconds < openSeconds || currentSeconds > closeSeconds
if (start != null && end != null) {
return isOutsideTimeWindow(now, start, end)
}
return isStoreClosedBySchedule(now)
}
export function hasLivePriceQuote(): boolean {
const p = lastPrice.value
return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0
return hasShowableHomePrice(lastPrice.value)
}
export function isStoreClosed(): boolean {
@@ -199,41 +287,81 @@ export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست'
/** سوییچ: خارج از ساعت کاری هم قیمت صفحه اصلی نشان داده شود */
export function shouldShowHomePriceWhenClosed(): boolean {
const settings = useSettingsStore()
return settings.isSettingTruthy('show_home_price_when_closed')
return useSettingsStore().isSettingTruthy('show_home_price_when_closed')
}
export function shouldShowLastSavedPrice(): boolean {
return useSettingsStore().isSettingTruthy('show_last_price')
}
/** فقط این منابع «آخرین قیمت ذخیره‌شده» هستند؛ باقی price_source مظنهٔ زنده است */
const STORED_PRICE_SOURCES = ['database_last']
function hasShowableHomePrice(p = lastPrice.value): boolean {
if (!p) return false
return Number(p.buy) > 0 || Number(p.sell) > 0 || Number(p.price_last) > 0 || Number(p.price) > 0
}
/** قیمت از مرجع زنده آمده یا از آخرین ردیف ذخیره‌شده */
function hasLiveQuote(p = lastPrice.value): boolean {
if (!hasShowableHomePrice(p)) return false
const source = String(p?.price_source ?? '').trim().toLowerCase()
if (source) return !STORED_PRICE_SOURCES.includes(source)
return (
Number(p?.montzar ?? 0) > 0
|| Number(p?.zaarbox ?? 0) > 0
|| Number(p?.main_product ?? 0) > 0
)
}
/**
* وضعیت نمایش قیمت:
* - closed → فروشگاه بسته است (ساعت کاری، سیگنال سرور، یا نبود مظنه)
* - open → نمایش عادی قیمت
*
* اگر show_home_price_when_closed روشن باشد و قیمت موجود باشد، به‌جای closed قیمت نشان داده می‌شود.
* وضعیت نمایش قیمت در صفحه اصلی — همان منطق بک‌اند:
* خارج از ساعت کاری (close_price روشن) → show_home_price_when_closed روشن: آخرین قیمت؛ خاموش: closed
* داخل ساعت کاری → مظنه زنده: open ؛ وگرنه فقط با show_last_price آخرین قیمت؛ وگرنه no_quote
*/
export function getPriceDisplayState(): PriceDisplayState {
void productTradingHoursVersion.value
void useSettingsStore().settingsRevision
const p = lastPrice.value
const showWhenClosed = shouldShowHomePriceWhenClosed()
if (p?.status === 'closed' || p?.closed === true) {
if (showWhenClosed && hasLivePriceQuote()) {
return 'open'
}
return 'closed'
const serverStatus = p?.status
// اگر عدد قیمت هست، no_quote لحظه‌ای سرور را جدی نگیر (race با تیک بعدی)
if (serverStatus === 'no_quote' && hasShowableHomePrice(p)) {
// fall through به منطق کلاینت
} else if (serverStatus === 'open' || serverStatus === 'closed' || serverStatus === 'no_quote') {
return serverStatus
}
if (isStoreClosed()) {
if (showWhenClosed && hasLivePriceQuote()) {
return 'open'
}
return 'closed'
if (isOutsideGlobalStoreHours()) {
if (!shouldShowHomePriceWhenClosed()) return 'closed'
return hasShowableHomePrice(p) ? 'open' : 'no_quote'
}
if (!hasLivePriceQuote()) {
return 'closed'
}
if (hasLiveQuote(p)) return 'open'
if (shouldShowLastSavedPrice() && hasShowableHomePrice(p)) return 'open'
if (hasShowableHomePrice(p)) return 'open'
return 'open'
return 'no_quote'
}
/**
* اسکلتون تا وقتی قیمت قابل‌نمایش برسد، یا وضعیت بسته قطعی شود،
* یا بعد از اولین پاسخ خالی پنجرهٔ settle تمام شود (مظنه واقعاً نیست).
*/
export function isHomeQuotePending(): boolean {
const settings = useSettingsStore()
void lastPriceReady.value
void lastPrice.value
void isLastPriceAbsenceSettled()
void settings.loaded
void settings.settingsRevision
if (!settings.loaded || !lastPriceReady.value) return true
if (hasShowableHomePrice(lastPrice.value)) return false
if (lastPrice.value?.status === 'closed') return false
return !isLastPriceAbsenceSettled()
}
export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {
+37
View File
@@ -0,0 +1,37 @@
/**
* نگاشت منو/مسیر اپ کاربر → کلید دسترسی سوپرادمین.
* مقدار false/غایب = مخفی (پیش‌فرض امن).
*/
export const ROUTE_FEATURE_SETTING: Record<string, string> = {
fund: 'enable_product_funds',
fund_info: 'enable_product_funds',
fund_add: 'enable_product_funds',
piggy: 'enable_product_funds',
earnMoney: 'has_page_earn_money',
giftCard: 'has_page_gift_card',
notifyTrigger: 'has_page_price_trigger',
appNotifications: 'has_page_notification',
conditionalOrder: 'enable_conditional_order',
permissions: 'permision__enabled',
}
export const MENU_ID_FEATURE_SETTING: Record<string, string> = {
fund: 'enable_product_funds',
piggy: 'enable_product_funds',
earnMoney: 'has_page_earn_money',
giftCard: 'has_page_gift_card',
notify: 'has_page_price_trigger',
notifyTrigger: 'has_page_price_trigger',
conditionalOrder: 'enable_conditional_order',
permissions: 'permision__enabled',
}
export function featureSettingForRoute(routeName: string | null | undefined): string | null {
if (!routeName) return null
return ROUTE_FEATURE_SETTING[String(routeName)] ?? null
}
export function featureSettingForMenuId(id: string | null | undefined): string | null {
if (!id) return null
return MENU_ID_FEATURE_SETTING[String(id)] ?? null
}
+5 -5
View File
@@ -1,5 +1,4 @@
import { ref } from 'vue'
import { getSettings } from '@/api/configuration'
import type { DepositGuideVars } from '@/utils/depositGuideText'
import {
DEFAULT_DEPOSIT_GUIDE_TEXT,
@@ -32,6 +31,7 @@ export interface SupportMessengerSettings {
depositSupportMessageText?: string
depositAccountHolder?: string
depositCardNumber?: string
depositAccountNumber?: string
depositSheba?: string
depositSmsSupportNumber?: string
}
@@ -175,6 +175,7 @@ export function getDepositGuideVarsFromSupportSettings(
return {
accountHolder: settings.depositAccountHolder,
cardNumber: settings.depositCardNumber,
accountNumber: settings.depositAccountNumber,
sheba: settings.depositSheba,
customerName: getCustomerNameFromUser(),
}
@@ -367,10 +368,8 @@ export async function loadSupportMessengerFromSettings(forceSettingsRefresh = fa
await settingsStore.fetchSettings()
}
const settings = await getSettings()
if (!settings || !Array.isArray(settings)) return
settingsStore.mergeSettings(settings)
const settings = settingsStore.settings
if (!settings?.length) return
supportMessengerSettings.value = {
messengers: resolveSupportMessengersFromSettings(settings),
@@ -380,6 +379,7 @@ export async function loadSupportMessengerFromSettings(forceSettingsRefresh = fa
depositSupportMessageText: getSupportSettingValue(settings, 'deposit_support_message_text'),
depositAccountHolder: getSupportSettingValue(settings, 'deposit_account_holder'),
depositCardNumber: getSupportSettingValue(settings, 'deposit_card_number'),
depositAccountNumber: getSupportSettingValue(settings, 'deposit_account_number'),
depositSheba: getSupportSettingValue(settings, 'deposit_sheba'),
depositSmsSupportNumber: getSupportSettingValue(settings, 'deposit_sms_support_number'),
}
+145
View File
@@ -0,0 +1,145 @@
import type { AppSetting } from '@/store/settingsStore'
import { useSettingsStore } from '@/store/settingsStore'
export const TRACKING_SYSTEMS_KEY = 'tracking_systems'
export const TRACKING_SYSTEMS_ENABLED_KEY = 'tracking_systems_enabled'
export interface TrackingSystemSite {
id: string
name: string
url: string
}
let trackingSettingsCache: AppSetting[] | null = null
let trackingSettingsPromise: Promise<AppSetting[]> | null = null
export function normalizeSettingsResponse(data: unknown): AppSetting[] {
if (Array.isArray(data)) {
return data as AppSetting[]
}
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
const nested = record.data ?? record.settings ?? record.rows
if (Array.isArray(nested)) {
return nested as AppSetting[]
}
}
return []
}
export function findSettingValue(settings: AppSetting[], key: string): unknown {
const setting = settings.find((item) => String(item?.key ?? '').trim() === key)
if (!setting) {
return null
}
return setting.value ?? null
}
export function isSettingTruthy(value: unknown): boolean {
if (value === true) {
return true
}
const normalized = String(value ?? '').trim().toLowerCase()
return normalized === 'true' || normalized === '1' || normalized === 'yes'
}
export function parseTrackingSystems(raw: unknown): TrackingSystemSite[] {
let value: unknown = raw
for (let depth = 0; depth < 2; depth += 1) {
if (typeof value !== 'string') {
break
}
const trimmed = value.trim()
if (!trimmed) {
return []
}
try {
value = JSON.parse(trimmed)
} catch {
return []
}
}
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => {
if (!item || typeof item !== 'object') {
return null
}
const record = item as Record<string, unknown>
const id = String(record.id ?? '').trim()
const name = String(record.name ?? record.title ?? '').trim()
const url = String(record.url ?? record.link ?? '').trim()
if (!name || !url) {
return null
}
return {
id: id || `${name}-${url}`,
name,
url,
}
})
.filter((item): item is TrackingSystemSite => item != null)
}
export function resolveTrackingSystems(settings: AppSetting[]): TrackingSystemSite[] {
const raw = findSettingValue(settings, TRACKING_SYSTEMS_KEY)
return parseTrackingSystems(raw)
}
export function isTrackingSystemsEnabled(settings: AppSetting[]): boolean {
const raw = findSettingValue(settings, TRACKING_SYSTEMS_ENABLED_KEY)
return isSettingTruthy(raw)
}
export async function fetchTrackingSettings(force = false): Promise<AppSetting[]> {
if (!force && trackingSettingsCache) {
return trackingSettingsCache
}
if (!force && trackingSettingsPromise) {
return trackingSettingsPromise
}
trackingSettingsPromise = (async () => {
const settingsStore = useSettingsStore()
try {
await settingsStore.fetchSettings(force)
} catch (error) {
console.log(error)
}
const settings = [...settingsStore.settings]
trackingSettingsCache = settings
return settings
})()
.catch((error) => {
console.log(error)
trackingSettingsCache = []
return []
})
.finally(() => {
trackingSettingsPromise = null
})
return trackingSettingsPromise
}
export function resetTrackingSettingsCache() {
trackingSettingsCache = null
trackingSettingsPromise = null
}
+45 -8
View File
@@ -7,6 +7,10 @@ import { findStoredAuthToken } from '@/utils/authToken';
import router from "@/router";
import {get} from 'lodash'
import {message} from "ant-design-vue";
import {
hideServerUnavailableOverlay,
showServerUnavailableOverlay,
} from '@/utils/serverUnavailable'
interface Meta {
errorMessage?: 'message' | 'notification' | 'toast' | 'none'
@@ -71,6 +75,7 @@ function scheduleSessionVerify() {
meta: { errorMessage: 'none' },
})
} catch {
showPleaseLoginAgain()
await logoutToLogin()
} finally {
sessionVerifyInFlight = false
@@ -100,10 +105,7 @@ _axios.interceptors.request.use(
)
_axios.interceptors.response.use(
(response) => {
//console.log('this is response')
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
//console.log('this is fulfilled on interceptors.response', response.data)
hideServerUnavailableOverlay()
return response.data
},
async function (error) {
@@ -115,6 +117,7 @@ _axios.interceptors.response.use(
if (hadAuth) {
const path = requestPath(config)
if (path.includes('/customer/me')) {
showPleaseLoginAgain()
await logoutToLogin()
} else {
scheduleSessionVerify()
@@ -123,34 +126,68 @@ _axios.interceptors.response.use(
return Promise.reject(error)
}
if (response?.status === 503) {
showServerUnavailableOverlay(SERVER_ERROR)
return Promise.reject(error)
}
if (!shouldShowApiError(config)) {
return Promise.reject(error)
}
const apiMessage = extractApiErrorMessage(response?.data)
const status = Number(response?.status ?? 0)
if (response?.status === 403) {
showApiError(apiMessage || 'شما دسترسی لازم برای انجام این عملیات را ندارید', config)
showApiError(
withHttpStatus(apiMessage || 'شما دسترسی لازم برای انجام این عملیات را ندارید', 403),
config,
)
return Promise.reject(error)
}
if (response?.status === 422) {
if (apiMessage) {
showApiError(apiMessage, config)
showApiError(withHttpStatus(apiMessage, 422), config)
} else {
showApiError(withHttpStatus('خطا در اعتبارسنجی', 422), config)
}
return Promise.reject(error)
}
if (response) {
showApiError(apiMessage || DEFAULT_LOAD_ERROR, config)
if (status >= 500) {
showApiError(withHttpStatus(SERVER_ERROR, status), config)
} else {
showApiError(withHttpStatus(apiMessage || DEFAULT_LOAD_ERROR, status), config)
}
return Promise.reject(error)
}
showApiError(DEFAULT_LOAD_ERROR, config)
showApiError(NETWORK_ERROR, config)
return Promise.reject(error)
})
const DEFAULT_LOAD_ERROR = 'خطا در بارگذاری اطلاعات'
const SERVER_ERROR = 'خطا در برقراری ارتباط با سرور'
const NETWORK_ERROR = 'لطفا اینترنت خود را بررسی کنید و مجددا تلاش کنید'
const LOGIN_AGAIN = 'لطفا مجددا وارد شوید'
let lastAuthToastAt = 0
function showPleaseLoginAgain() {
const now = Date.now()
if (now - lastAuthToastAt < 2500) return
lastAuthToastAt = now
message.error({
content: LOGIN_AGAIN,
key: 'please-login-again',
duration: 3,
})
}
function withHttpStatus(message: string, status: number): string {
return `${message} (${status})`
}
function extractApiErrorMessage(data: unknown): string {
if (!data || typeof data !== 'object') {