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:
+3
-2
@@ -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')) &&
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 لوکال میخواند */
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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')} ریال پرداخت`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user