new update

This commit is contained in:
2026-06-29 15:27:35 +03:30
parent 2e159dde20
commit be17631109
33 changed files with 3713 additions and 351 deletions
+688 -16
View File
@@ -1,16 +1,129 @@
<!DOCTYPE html>
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="theme-color" content="#ffffff">
<title>Kashkol</title>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<title>در حال بارگذاری...</title>
<!---->
<style>
:root {
--boot-bg: #ffffff;
--boot-bg-soft: #f8fafc;
--boot-card: rgba(255, 255, 255, 0.88);
--boot-border: rgba(15, 23, 42, 0.08);
--boot-text: #111827;
--boot-muted: rgba(15, 23, 42, 0.62);
--boot-accent: #2563eb;
--boot-orb: rgba(37, 99, 235, 0.10);
--boot-spinner-track: rgba(15, 23, 42, 0.10);
--boot-shadow: rgba(15, 23, 42, 0.10);
}
html,
body {
margin: 0;
min-height: 100%;
background: var(--boot-bg);
}
#app {
min-height: 100vh;
}
.boot-loader {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
direction: rtl;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background:
radial-gradient(circle at top, var(--boot-orb), transparent 38%),
linear-gradient(
135deg,
var(--boot-bg) 0%,
var(--boot-bg-soft) 100%
);
color: var(--boot-text);
overflow: hidden;
}
.boot-loader__card {
position: relative;
width: min(340px, calc(100vw - 48px));
padding: 30px 22px 28px;
border-radius: 24px;
text-align: center;
background: var(--boot-card);
border: 1px solid var(--boot-border);
box-shadow: 0 18px 48px var(--boot-shadow);
backdrop-filter: blur(10px);
}
.boot-loader__orb {
position: absolute;
top: -72px;
left: 50%;
width: 150px;
height: 150px;
transform: translateX(-50%);
border-radius: 999px;
background: var(--boot-orb);
filter: blur(8px);
pointer-events: none;
}
.boot-loader__spinner {
position: relative;
z-index: 1;
width: 56px;
height: 56px;
margin: 0 auto 20px;
border-radius: 999px;
border: 4px solid var(--boot-spinner-track);
border-top-color: var(--boot-accent);
animation: boot-spin 0.9s linear infinite;
}
.boot-loader__title {
position: relative;
z-index: 1;
margin: 0;
font-size: 19px;
font-weight: 800;
letter-spacing: -0.02em;
}
.boot-loader__text {
position: relative;
z-index: 1;
margin: 10px 0 0;
color: var(--boot-muted);
font-size: 14px;
line-height: 2;
}
@keyframes boot-spin {
to {
transform: rotate(360deg);
}
}
#whatsapp-button {
position: fixed;
bottom: 90px;
@@ -43,18 +156,577 @@
#whatsapp-button img {
pointer-events: none;
}
#whatsapp-button:hover + #whatsapp-tooltip {
opacity: 1;
transform: translateY(0);
}
</style>
<script>
;(function () {
var CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
function getCookie(name) {
var cookie = document.cookie.split('; ').find(function (row) {
return row.indexOf(name + '=') === 0
})
if (!cookie) return null
return cookie.substring(name.length + 1)
}
function readCachedConfig() {
try {
var rawCookie = getCookie(CONFIG_COOKIE_NAME)
if (!rawCookie) return null
var decoded = decodeURIComponent(rawCookie)
var payload = JSON.parse(decoded)
return payload && payload.config ? payload.config : null
} catch (error) {
console.warn('Boot theme: failed to read cached config', error)
return null
}
}
function normalizeHex(hex) {
if (!hex || typeof hex !== 'string') return null
var value = hex.trim()
if (!value) return null
if (value[0] !== '#') {
value = '#' + value
}
if (/^#[0-9a-fA-F]{3}$/.test(value)) {
value =
'#' +
value[1] +
value[1] +
value[2] +
value[2] +
value[3] +
value[3]
}
if (!/^#[0-9a-fA-F]{6}$/.test(value)) return null
return value
}
function hexToRgb(hex) {
var normalized = normalizeHex(hex)
if (!normalized) return null
var num = parseInt(normalized.slice(1), 16)
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255
}
}
function rgbToHex(r, g, b) {
function toHex(value) {
var hex = Math.max(0, Math.min(255, Math.round(value))).toString(16)
return hex.length === 1 ? '0' + hex : hex
}
return '#' + toHex(r) + toHex(g) + toHex(b)
}
function mix(hexA, hexB, weight) {
var a = hexToRgb(hexA)
var b = hexToRgb(hexB)
if (!a || !b) return hexA
return rgbToHex(
a.r * (1 - weight) + b.r * weight,
a.g * (1 - weight) + b.g * weight,
a.b * (1 - weight) + b.b * weight
)
}
function brightness(hex) {
var rgb = hexToRgb(hex)
if (!rgb) return 0
return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
}
function hexToRgba(hex, alpha) {
var rgb = hexToRgb(hex)
if (!rgb) return 'rgba(96, 165, 250, ' + alpha + ')'
return 'rgba(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ', ' + alpha + ')'
}
function setVar(name, value) {
document.documentElement.style.setProperty(name, value)
}
function applyBootTheme() {
var config = readCachedConfig()
var user = config && config.user ? config.user : null
if (!user) return
var tenantColor = normalizeHex(user.color)
var tenantSecondary = normalizeHex(user.secondary_color)
var tenantBackground = normalizeHex(user.background_color)
/**
* مهم:
* background_color بک‌گراند اپ است.
* اگر نبود، لودینگ باید روشن بماند، نه اینکه color را بک‌گراند کنیم.
*/
var bg = tenantBackground || '#ffffff'
/**
* color و secondary_color فقط برای accent استفاده می‌شوند.
*/
var accent = tenantSecondary || tenantColor || '#2563eb'
var isLight = brightness(bg) > 155
var bgSoft = isLight ? mix(bg, accent, 0.045) : mix(bg, '#ffffff', 0.055)
var text = isLight ? '#111827' : '#ffffff'
var muted = isLight ? 'rgba(15, 23, 42, 0.62)' : 'rgba(255, 255, 255, 0.70)'
var card = isLight ? 'rgba(255, 255, 255, 0.88)' : 'rgba(255, 255, 255, 0.07)'
var border = isLight ? 'rgba(15, 23, 42, 0.08)' : 'rgba(255, 255, 255, 0.12)'
var orb = hexToRgba(accent, isLight ? 0.10 : 0.18)
var spinnerTrack = isLight ? 'rgba(15, 23, 42, 0.10)' : 'rgba(255, 255, 255, 0.14)'
var shadow = isLight ? 'rgba(15, 23, 42, 0.10)' : 'rgba(0, 0, 0, 0.24)'
setVar('--boot-bg', bg)
setVar('--boot-bg-soft', bgSoft)
setVar('--boot-accent', accent)
setVar('--boot-text', text)
setVar('--boot-muted', muted)
setVar('--boot-card', card)
setVar('--boot-border', border)
setVar('--boot-orb', orb)
setVar('--boot-spinner-track', spinnerTrack)
setVar('--boot-shadow', shadow)
document.title = user.name_fa || user.name || 'در حال بارگذاری...'
}
applyBootTheme()
})()
</script>
</head>
<body>
<div id="app"></div>
<div id="app">
<div class="boot-loader">
<div class="boot-loader__card">
<div class="boot-loader__orb"></div>
<div class="boot-loader__spinner"></div>
<h1 class="boot-loader__title">در حال هدایت به سامانه</h1>
<p class="boot-loader__text">لطفاً چند لحظه صبر کنید...</p>
</div>
</div>
</div>
<script>
;(function () {
var BOOT_TIMEOUT_MS = 90000
var RETRY_KEY = '__APP_BOOT_RETRY_CLICKED__'
var RECOVERY_KEY = '__APP_BOOT_RECOVERY_DONE__'
var CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
var CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
var TOKEN_STORAGE_KEY = 'liman_user_token'
var VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
var VERSION_STORAGE_KEY = 'version'
var CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
function getProtocol() {
return window.location.protocol === 'https:' ? 'https:' : 'http:'
}
function getVersionUrls() {
return ['https://nerkh.gold/get_version', 'https://tablo.talasevin.ir/get_version', 'https://abshode-service.montgit.ir/server/get_version']
}
function getConfigUrls() {
var protocol = getProtocol()
return [
protocol + '//nerkh.gold/get_user',
protocol + '//tablo.talasevin.ir/get_user',
protocol + '//abshode-service.montgit.ir/server/get_user',
protocol + '//212.23.201.173:8381/get_user',
protocol + '//91.212.174.137:4040/server/get_user'
]
}
function sortVersionUrls(urls) {
var lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
if (!lastSuccessUrl) {
return urls.slice()
}
return [lastSuccessUrl].concat(urls.filter(function (url) {
return url !== lastSuccessUrl
}))
}
function sortByLastSuccess(urls) {
var lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
return urls.slice().sort(function (a, b) {
if (a === lastSuccessUrl) return -1
if (b === lastSuccessUrl) return 1
return 0
})
}
function normalizeVersionResponse(data) {
if (typeof data === 'string' || typeof data === 'number') {
return String(data).trim()
}
if (!data) return ''
if (typeof data.data === 'string' || typeof data.data === 'number') {
return String(data.data).trim()
}
var version =
data.version ||
data.value ||
(data.data && typeof data.data === 'object' ? data.data.version : '')
return version ? String(version).trim() : ''
}
async function fetchJsonOrText(url, timeout) {
var controller = new AbortController()
var timeoutId = window.setTimeout(function () {
controller.abort()
}, timeout)
try {
var response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
},
cache: 'no-store',
signal: controller.signal
})
if (!response.ok) {
throw new Error('Request failed: ' + response.status)
}
var contentType = response.headers.get('content-type') || ''
if (contentType.indexOf('application/json') !== -1) {
return await response.json()
}
return await response.text()
} finally {
window.clearTimeout(timeoutId)
}
}
async function fetchVersionFromUrl(url, timeout) {
var controller = new AbortController()
var timeoutId = window.setTimeout(function () {
controller.abort()
}, timeout)
try {
var response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
},
cache: 'no-store',
signal: controller.signal
})
if (!response.ok) {
throw new Error('Version request failed: ' + response.status)
}
var contentType = response.headers.get('content-type') || ''
var data
if (contentType.indexOf('application/json') !== -1) {
data = await response.json()
} else {
data = await response.text()
}
var version = normalizeVersionResponse(data)
if (!version) {
throw new Error('Version response is empty')
}
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
return version
} catch (error) {
console.warn('Version failed:', url, error)
return undefined
} finally {
window.clearTimeout(timeoutId)
}
}
async function loadRemoteVersion(rounds) {
var urls = sortVersionUrls(getVersionUrls())
for (var round = 1; round <= rounds; round++) {
for (var i = 0; i < urls.length; i++) {
var timeout = i === urls.length - 1 ? 6000 : 3500
var result = await fetchVersionFromUrl(urls[i], timeout)
if (result) {
return result
}
}
}
return null
}
async function fetchFreshConfig(rounds) {
var urls = sortByLastSuccess(getConfigUrls())
for (var round = 1; round <= rounds; round++) {
for (var i = 0; i < urls.length; i++) {
var url = urls[i]
try {
var config = await fetchJsonOrText(url, i === urls.length - 1 ? 7000 : 4000)
if (!config || typeof config !== 'object') {
throw new Error('Config response is invalid')
}
return {
config: config,
url: url
}
} catch (error) {
console.warn('Config failed:', url, 'round:', round, error)
}
}
}
throw new Error('Cannot fetch fresh config')
}
function writeConfigCookie(config) {
try {
var payload = {
cached_at: Date.now(),
config: config
}
var encoded = encodeURIComponent(JSON.stringify(payload))
var secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie =
CONFIG_COOKIE_NAME +
'=' +
encoded +
'; Path=/; Max-Age=' +
CONFIG_COOKIE_MAX_AGE_SECONDS +
'; SameSite=Lax' +
secure
} catch (error) {
console.warn('Failed to write config cookie:', error)
}
}
function clearReadableCookies() {
document.cookie.split(';').forEach(function (cookie) {
var name = cookie.split('=')[0] && cookie.split('=')[0].trim()
if (!name) return
document.cookie = name + '=; Path=/; Max-Age=0; SameSite=Lax'
})
}
function clearLocalStorageExceptToken() {
var token = localStorage.getItem(TOKEN_STORAGE_KEY)
localStorage.clear()
if (token) {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
}
}
async function clearServiceWorkersAndCaches() {
try {
if ('serviceWorker' in navigator) {
var registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(
registrations.map(function (registration) {
return registration.unregister()
})
)
}
} catch (error) {
console.warn('Failed to unregister service workers:', error)
}
try {
if ('caches' in window) {
var keys = await caches.keys()
await Promise.all(
keys.map(function (key) {
return caches.delete(key)
})
)
}
} catch (error) {
console.warn('Failed to clear caches:', error)
}
}
function reloadWithCacheBust() {
var url = new URL(window.location.href)
url.searchParams.set('__boot_recover', String(Date.now()))
window.location.replace(url.toString())
}
function renderRecovering(text) {
var app = document.getElementById('app')
if (!app) return
app.innerHTML =
'<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg); color: var(--boot-text);">' +
'<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card); border: 1px solid var(--boot-border); border-radius: 24px; padding: 28px 22px;">' +
'<div style="width: 54px; height: 54px; margin: 0 auto 18px; border-radius: 999px; border: 4px solid rgba(255,255,255,0.18); border-top-color: var(--boot-accent); animation: bootSpin 0.9s linear infinite;"></div>' +
'<style>@keyframes bootSpin { to { transform: rotate(360deg); } }</style>' +
'<h2 style="margin: 0 0 12px; font-size: 20px;">در حال بازیابی برنامه</h2>' +
'<p style="margin: 0; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
(text || 'لطفاً چند لحظه صبر کنید.') +
'</p>' +
'</div>' +
'</div>'
}
function renderBootError() {
var app = document.getElementById('app')
if (!app) return
app.innerHTML =
'<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg); color: var(--boot-text);">' +
'<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card); border: 1px solid var(--boot-border); border-radius: 24px; padding: 28px 22px;">' +
'<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>' +
'<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در بارگذاری برنامه</h2>' +
'<p style="margin: 0 0 20px; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
'ارتباط با سرور برقرار نشد یا فایل‌های برنامه به‌درستی دریافت نشدند.' +
'</p>' +
'<button onclick="window.__APP_BOOT_SIMPLE_RETRY__()" style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent); color: white; cursor: pointer; font-weight: 700;">' +
'تلاش مجدد' +
'</button>' +
'<button onclick="window.__APP_BOOT_CLEAN_RELOAD__()" style="margin-right: 8px; padding: 11px 18px; border: 1px solid var(--boot-border); border-radius: 12px; background: transparent; color: var(--boot-text); cursor: pointer; font-weight: 700;">' +
'پاک‌سازی و تلاش مجدد' +
'</button>' +
'</div>' +
'</div>'
}
async function cleanFetchAndReload() {
renderRecovering('در حال دریافت تنظیمات جدید برنامه...')
var version = await loadRemoteVersion(5)
var freshConfigResult = await fetchFreshConfig(2)
clearReadableCookies()
// clearLocalStorageExceptToken()
await clearServiceWorkersAndCaches()
writeConfigCookie(freshConfigResult.config)
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, freshConfigResult.url)
if (version) {
localStorage.setItem(VERSION_STORAGE_KEY, version)
}
sessionStorage.removeItem(RETRY_KEY)
sessionStorage.removeItem(RECOVERY_KEY)
reloadWithCacheBust()
}
window.__APP_BOOT_SIMPLE_RETRY__ = function () {
sessionStorage.setItem(RETRY_KEY, '1')
reloadWithCacheBust()
}
window.__APP_BOOT_CLEAN_RELOAD__ = async function () {
try {
sessionStorage.setItem(RECOVERY_KEY, '1')
await cleanFetchAndReload()
} catch (error) {
console.error('Manual boot recovery failed:', error)
renderBootError()
}
}
window.__BOOT_TIMEOUT__ = window.setTimeout(async function () {
if (window.__APP_BOOTSTRAPPED__) return
var app = document.getElementById('app')
var stillBooting = app && app.querySelector('.boot-loader')
if (!stillBooting) return
if (!sessionStorage.getItem(RETRY_KEY)) {
renderBootError()
return
}
if (!sessionStorage.getItem(RECOVERY_KEY)) {
sessionStorage.setItem(RECOVERY_KEY, '1')
try {
await cleanFetchAndReload()
} catch (error) {
console.error('Auto boot recovery after retry failed:', error)
renderBootError()
}
return
}
renderBootError()
}, BOOT_TIMEOUT_MS)
})()
</script>
<script type="module" src="/src/main.ts"></script>
<a href="#" target="_blank" id="whatsapp-button">
<img class="p-2" src="/src/assets/img/rubika.webp" alt="" />
</a>
</body>
</html>
</html>
-10
View File
@@ -1,10 +0,0 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "ir.kashkol.app",
"sha256_cert_fingerprints": ["60:54:C6:C1:22:6C:2E:EA:A1:05:02:B1:17:F5:7D:49:1A:C0:84:FF:4C:ED:FD:42:D9:7F:DF:B2:70:37:8E:03"]
}
}
]
+5 -42
View File
@@ -55,42 +55,25 @@
</div>
</div>
</transition>
<van-dialog
v-model:show="versionDialog"
class="updateButton"
confirm-button-text="به روزرسانی کنید"
show-confirm-button
@confirm="update()"
>
<div class="flex flex-col items-center p-2">
<DotLottieVue
class="w-full aspect-square"
autoplay
loop
src="https://lottie.host/cb620e8e-c64a-42e8-a6b5-8844146e5999/nmft7oBO8q.json"
/>
<div class="home_appear_fade_in_up mt-2">نسخه جدید کشکول گلد منتشر شد!</div>
</div>
</van-dialog>
<VersionUpdateModal />
</div>
</template>
<script lang="ts" setup>
import { colors } from './themeConfig'
import pkg from '../package.json'
import { useRegisterSW } from 'virtual:pwa-register/vue'
import { h, onMounted, ref } from 'vue'
import { update } from 'lodash'
import { defineAsyncComponent, h, onMounted, ref } from 'vue'
import img_topGradiant from '@/views/login/assets/img/gr_top_white.svg'
import img_pattern from '@/views/login/assets/img/pattern.svg'
import { LoadingOutlined } from '@ant-design/icons-vue'
import img_bottomGradiant from '@/views/login/assets/img/gr_bottom_white.svg'
import logoFull from '@/assets/img/logo_full_.png'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW()
const VersionUpdateModal = defineAsyncComponent(
() => import('@/modules/version/VersionUpdateModal.vue')
)
const indicator = h(LoadingOutlined, {
style: {
@@ -101,31 +84,11 @@ const indicator = h(LoadingOutlined, {
const showSplash = ref(true)
onMounted(() => {
checkVersion()
setTimeout(() => {
showSplash.value = false
}, 2000)
})
const versionDialog = ref<boolean>(false)
function checkVersion() {
const version = localStorage.getItem('version')
// if (true) {
if (!version){
update()
}
if (version != pkg.version) {
versionDialog.value = true
}
}
function update() {
localStorage.setItem('version', pkg.version)
updateServiceWorker()
window.location.reload()
}
</script>
<style lang="less">
+53
View File
@@ -0,0 +1,53 @@
import type { RouteLocationNormalized } from 'vue-router'
import router from '@/router'
import { message } from 'ant-design-vue'
let fingerPosition = false
let fingerTimeoutId: ReturnType<typeof setTimeout> | null = null
let hasFingerPrint = false
export function setTruth() {
fingerPosition = true
}
export function setEnableAuthenticate() {
fingerPosition = false
}
export function setFingerPosition(value: boolean) {
fingerPosition = value
}
export function getFingerPosition() {
return fingerPosition
}
export function setHasFingerPrint(value: boolean) {
hasFingerPrint = value
}
export function getHasFingerPrint() {
return hasFingerPrint
}
export function clearFingerTimeout() {
if (fingerTimeoutId) {
clearTimeout(fingerTimeoutId)
fingerTimeoutId = null
}
}
export function setFingerTimeOut(to: RouteLocationNormalized) {
clearFingerTimeout()
fingerTimeoutId = setTimeout(async () => {
if (!hasFingerPrint) return
fingerPosition = false
await router.push({ name: 'login' })
if (to.name !== 'login') {
message.warn('شما مدت زیادی با کشکول گلد کار نکردید، لطفا دوباره وارد شوید.')
}
}, 180000)
}
+88
View File
@@ -0,0 +1,88 @@
import { loadAppConfig } from '@/config/load-config'
import { setAppConfig } from '@/config/app-config'
import { applyAppConfig } from '@/config/apply-config'
import { startPwaManifest } from '@/modules/pwa/manifest'
import { createVueApp } from '@/app/createApp'
import { setupRouterGuards } from '@/app/setupRouter'
import { useSettingsStore } from '@/store/settingsStore'
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
type BootWindow = Window & {
__BOOT_TIMEOUT__?: number
__APP_BOOTSTRAPPED__?: boolean
}
function clearBootTimeout() {
const bootWindow = window as BootWindow
if (bootWindow.__BOOT_TIMEOUT__) {
window.clearTimeout(bootWindow.__BOOT_TIMEOUT__)
bootWindow.__BOOT_TIMEOUT__ = undefined
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => {
window.setTimeout(() => {
reject(new Error(message))
}, ms)
})
])
}
function showBootstrapError(error: unknown) {
clearBootTimeout()
console.error('Bootstrap failed:', error)
const appRoot = document.querySelector('#app')
if (!appRoot) return
appRoot.innerHTML = `
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg, #ffffff); color: var(--boot-text, #111827);">
<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card, #fff); border: 1px solid var(--boot-border, 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 12px; font-size: 20px;">خطا در راه‌اندازی برنامه</h2>
<p style="margin: 0 0 20px; color: var(--boot-muted, rgba(15,23,42,0.62)); line-height: 2; font-size: 14px;">
لطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید.
</p>
<button
onclick="location.reload()"
style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent, #2563eb); color: white; cursor: pointer; font-weight: 700;"
>
تلاش مجدد
</button>
</div>
</div>
`
}
export async function bootstrapApp() {
try {
const config = await withTimeout(loadAppConfig(), 20000, 'Config loading timeout')
setAppConfig(config)
startPwaManifest(config)
applyAppConfig()
const { app } = createVueApp()
const settingsStore = useSettingsStore()
await withTimeout(settingsStore.fetchSettings(), 45000, 'Settings loading timeout')
initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key))
setupRouterGuards()
app.mount('#app')
;(window as BootWindow).__APP_BOOTSTRAPPED__ = true
clearBootTimeout()
sessionStorage.removeItem('__APP_BOOT_RECOVERY_DONE__')
sessionStorage.removeItem('__APP_BOOT_RETRY_CLICKED__')
} catch (error) {
showBootstrapError(error)
}
}
+18
View File
@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import VueLazyLoad from 'vue3-lazyload'
import VueApexCharts from 'vue3-apexcharts'
import App from '@/App.vue'
import router from '@/router'
export function createVueApp() {
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(VueLazyLoad, {})
app.use(router)
app.use(VueApexCharts)
return { app, pinia }
}
+112
View File
@@ -0,0 +1,112 @@
import { computed, watch } from 'vue'
import router from '@/router'
import { authenticateWithFingerprint, getToken, isIphone, removeToken } from '@/utils'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useFundUserStore } from '@/store/fundStore'
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
import { useAuthStore } from '@/store/useAuthStore'
import {
clearFingerTimeout,
getFingerPosition,
getHasFingerPrint,
setFingerPosition,
setFingerTimeOut,
setHasFingerPrint
} from '@/app/authBoot'
export function setupRouterGuards() {
const userStore = useUserStore()
const walletStore = useWalletStore()
const fundStore = useFundUserStore()
const authStore = useAuthStore()
const isApple: boolean = isIphone()
let isFirst = true
const currentRoute = computed(() => router.currentRoute.value.fullPath)
async function start() {
if (getToken()) {
await walletStore.getLimitedPrice()
await walletStore.getLimitedBuyGold()
await fundStore.getMyFund()
}
}
function goToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) {
if (to.name === 'buyProduct' || to.name === 'productDetail' || to.name === 'about' || to.name === 'cart') {
return next()
}
if (to.name !== 'login') {
return next({ name: 'login' })
}
return next()
}
function goToHome(to: RouteLocationNormalized, next: NavigationGuardNext) {
if (to.name !== 'buyProduct') {
return next({ name: 'buyProduct' })
}
return next()
}
router.beforeEach(async (to, from, next) => {
authStore.setLoading(true)
clearFingerTimeout()
setFingerTimeOut(to)
if (isFirst) {
isFirst = false
if (getToken()) {
await userStore.getMyInfo()
const finger_print = userStore.userInfo.finger_print
setHasFingerPrint(finger_print)
if (isApple && finger_print) {
removeToken()
goToLogin(to, next)
} else if (!isApple && finger_print) {
setFingerPosition(await authenticateWithFingerprint())
if (!getFingerPosition()) {
goToLogin(to, next)
}
} else {
if (to.name == 'login') {
goToHome(to, next)
}
return next()
}
} else {
if (['login', '404', 'buyProduct', 'productDetail', 'about', 'cart'].includes(to.name as string)) {
return next()
}
goToLogin(to, next)
}
} else {
if (getFingerPosition()) {
if (getToken()) {
if (to.name === 'login') {
return next({ name: from.name || 'buyProduct' })
}
return next()
}
if (['login', '404', 'buyProduct', 'productDetail', 'about', 'cart'].includes(to.name as string)) {
return next()
}
return next({ name: 'login' })
}
if (getToken() && !getHasFingerPrint()) {
next()
} else {
goToLogin(to, next)
}
}
})
watch(currentRoute, async () => (userStore.userInfo ? '' : await start()))
}
@@ -41,11 +41,6 @@ const _class = computed(() => {
return ''
})
const _loading = computed(() => {
if (props.loading){
return 'flex'
} else return 'unset'
})
const { prefixCls } = usePrefix('base-button')
const props = withDefaults(defineProps<Props>(), {
@@ -58,9 +53,6 @@ const props = withDefaults(defineProps<Props>(), {
<style lang="less">
@prefix:~'@{prefixCls}-base-button';
.@{prefix}{
//width: fit-content;
height: auto;
/*padding: 8px;*/
display: v-bind(_loading);
}
</style>
+37
View File
@@ -0,0 +1,37 @@
import type { AppConfig } from './types'
type RawAppConfig = AppConfig & {
user?: Record<string, any>
}
let appConfig: RawAppConfig | null = null
export function setAppConfig(config: RawAppConfig) {
appConfig = config || {}
window.__APP_CONFIG__ = appConfig
}
export function getRawAppConfig() {
return appConfig ?? window.__APP_CONFIG__ ?? null
}
export function getTenantConfig() {
const rawConfig = getRawAppConfig()
if (rawConfig?.user && typeof rawConfig.user === 'object') {
return rawConfig.user
}
if (rawConfig && Object.keys(rawConfig).length > 0) {
return rawConfig
}
return {}
}
export function getConfig<T = any>(key: string, fallback?: T): T {
const config = getTenantConfig()
const value = config?.[key]
return (value ?? fallback ?? null) as T
}
+83
View File
@@ -0,0 +1,83 @@
import { getConfig } from '@/config/app-config'
import { isPwaManifestReady } from '@/modules/pwa/manifest'
export function applyAppConfig() {
if (isPwaManifestReady()) return
setTitle()
setFavicon()
setAppleTouchIconFallback()
}
function setTitle() {
document.title = getConfig('name_fa', 'در حال بارگذاری...')
}
function getLogoUrl() {
return getConfig<string>('avatar') || '/favicon.png'
}
function setFavicon() {
let faviconLink =
document.querySelector<HTMLLinkElement>("link[rel='icon']") ||
document.querySelector<HTMLLinkElement>("link[rel~='icon']")
if (!faviconLink) {
faviconLink = document.createElement('link')
faviconLink.rel = 'icon'
document.head.appendChild(faviconLink)
}
const logoUrl = getLogoUrl()
faviconLink.type = 'image/png'
faviconLink.href = logoUrl.startsWith('http') ? logoUrl : resolveRemoteLogo(logoUrl)
}
function setAppleTouchIconFallback() {
const logoUrl = getLogoUrl()
if (!logoUrl) return
if (document.querySelector("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")) {
return
}
const sizes = ['180x180', '152x152', '120x120']
sizes.forEach((size) => {
let link = document.querySelector<HTMLLinkElement>(
`link[rel='apple-touch-icon'][sizes='${size}']`
)
if (!link) {
link = document.createElement('link')
link.rel = 'apple-touch-icon'
link.sizes = size
document.head.appendChild(link)
}
link.href = logoUrl.startsWith('http') ? logoUrl : resolveRemoteLogo(logoUrl)
})
let appleTitle = document.querySelector<HTMLMetaElement>("meta[name='apple-mobile-web-app-title']")
if (!appleTitle) {
appleTitle = document.createElement('meta')
appleTitle.name = 'apple-mobile-web-app-title'
document.head.appendChild(appleTitle)
}
appleTitle.content = getConfig('name_fa', 'اپ مشتری')
}
function resolveRemoteLogo(path: string) {
if (/^(https?:\/\/|data:|blob:)/i.test(path)) return path
const lastSuccess = localStorage.getItem('CONFIG_Success_URL')
const base = lastSuccess
? lastSuccess.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
: 'https://nerkh.gold'
return `${base}/${path.replace(/^\/+/, '')}`
}
+277
View File
@@ -0,0 +1,277 @@
import type { AppConfig } from '@/config/types'
import { resetVersionCheckCache } from '@/config/load-version'
import { clearCachedPwaManifest } from '@/modules/pwa/manifest-cache'
import { tokenName } from '@/themeConfig'
const CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
const CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
const MAX_COOKIE_VALUE_LENGTH = 3500
const VERSION_STORAGE_KEY = 'version'
const VERSION_UPDATE_LOCK_KEY = 'version_update_lock'
const VERSION_LAST_RELOAD_KEY = 'version_last_reload'
type CachedConfigPayload = {
cached_at: number
config: AppConfig
}
type VersionUpdateLock = {
version: string
started_at: number
}
function getCookie(name: string) {
const cookie = document.cookie.split('; ').find((row) => row.startsWith(`${name}=`))
if (!cookie) return null
return cookie.substring(name.length + 1)
}
function setCookie(name: string, value: string, maxAgeSeconds: number) {
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie = [
`${name}=${value}`,
'Path=/',
`Max-Age=${maxAgeSeconds}`,
'SameSite=Lax',
secure
].join('; ')
}
export function readCachedAppConfig(): AppConfig | null {
try {
const rawCookie = getCookie(CONFIG_COOKIE_NAME)
if (!rawCookie) return null
const decoded = decodeURIComponent(rawCookie)
const payload = JSON.parse(decoded) as CachedConfigPayload
if (!payload?.config) return null
return payload.config
} catch (error) {
console.warn('Failed to read cached app config:', error)
clearCachedAppConfig()
return null
}
}
export function writeCachedAppConfig(config: AppConfig) {
try {
const payload: CachedConfigPayload = {
cached_at: Date.now(),
config
}
const encoded = encodeURIComponent(JSON.stringify(payload))
if (encoded.length > MAX_COOKIE_VALUE_LENGTH) {
console.warn(`App config is too large for cookie cache. Size: ${encoded.length}`)
return
}
setCookie(CONFIG_COOKIE_NAME, encoded, CONFIG_COOKIE_MAX_AGE_SECONDS)
} catch (error) {
console.warn('Failed to write app config cache:', error)
}
}
export function clearCachedAppConfig() {
document.cookie = `${CONFIG_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax`
clearCachedPwaManifest()
}
export function getStoredAppVersion() {
return localStorage.getItem(VERSION_STORAGE_KEY)
}
export function writeStoredAppVersion(version: string) {
localStorage.setItem(VERSION_STORAGE_KEY, String(version).trim())
}
export function getNormalizedVersion(version: unknown) {
if (version === null || version === undefined) return ''
return String(version).trim()
}
export function shouldUpdateForVersion(serverVersion: unknown) {
const normalizedServerVersion = getNormalizedVersion(serverVersion)
if (!normalizedServerVersion) return false
const storedVersion = getStoredAppVersion()
if (!storedVersion) {
writeStoredAppVersion(normalizedServerVersion)
return false
}
return storedVersion !== normalizedServerVersion
}
export function canStartVersionUpdate(serverVersion: string) {
const version = getNormalizedVersion(serverVersion)
if (!version) return false
const lastReloadVersion = sessionStorage.getItem(VERSION_LAST_RELOAD_KEY)
if (lastReloadVersion === version) {
return false
}
const rawLock = sessionStorage.getItem(VERSION_UPDATE_LOCK_KEY)
if (rawLock) {
try {
const lock = JSON.parse(rawLock) as VersionUpdateLock
const isSameVersion = lock.version === version
const isFreshLock = Date.now() - lock.started_at < 60_000
if (isSameVersion && isFreshLock) {
return false
}
} catch {
sessionStorage.removeItem(VERSION_UPDATE_LOCK_KEY)
}
}
return true
}
export function startVersionUpdateLock(serverVersion: string) {
const version = getNormalizedVersion(serverVersion)
const lock: VersionUpdateLock = {
version,
started_at: Date.now()
}
sessionStorage.setItem(VERSION_UPDATE_LOCK_KEY, JSON.stringify(lock))
}
export function finishVersionUpdateBeforeReload(serverVersion: string) {
const version = getNormalizedVersion(serverVersion)
writeStoredAppVersion(version)
sessionStorage.setItem(VERSION_LAST_RELOAD_KEY, version)
sessionStorage.removeItem(VERSION_UPDATE_LOCK_KEY)
}
export function cancelVersionUpdateLock() {
sessionStorage.removeItem(VERSION_UPDATE_LOCK_KEY)
}
function clearReadableCookiesExceptConfigAlreadyHandled() {
document.cookie.split(';').forEach((cookie) => {
const name = cookie.split('=')[0]?.trim()
if (!name) return
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`
})
}
export async function clearBrowserRuntimeCaches() {
try {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map((registration) => registration.unregister()))
}
} catch (error) {
console.warn('Could not unregister service workers:', error)
}
try {
if (!('caches' in window)) return
const cacheKeys = await caches.keys()
await Promise.all(cacheKeys.map((key) => caches.delete(key)))
} catch (error) {
console.warn('Could not clear browser caches:', error)
}
}
function clearLocalStorageExceptToken() {
const token = localStorage.getItem(tokenName)
localStorage.clear()
if (token) {
localStorage.setItem(tokenName, token)
}
}
export function clearAppStorageExceptToken() {
clearReadableCookiesExceptConfigAlreadyHandled()
clearLocalStorageExceptToken()
sessionStorage.clear()
}
export function clearAllBootstrapFetchCaches() {
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
clearCachedPwaManifest()
resetVersionCheckCache()
window.__APP_CONFIG__ = undefined
}
export function hardReloadApp() {
const refreshUrl = `${window.location.origin}${window.location.pathname}?__app_refresh=${Date.now()}`
window.location.href = refreshUrl
}
export async function refreshConfigCacheForVersion(
newVersion: string,
fetchConfig: () => Promise<AppConfig>
) {
const normalizedVersion = getNormalizedVersion(newVersion)
if (!normalizedVersion) {
throw new Error('Version is empty')
}
startVersionUpdateLock(normalizedVersion)
clearCachedAppConfig()
clearAppStorageExceptToken()
clearAllBootstrapFetchCaches()
await clearBrowserRuntimeCaches()
const freshConfig = await fetchConfig()
writeCachedAppConfig(freshConfig)
finishVersionUpdateBeforeReload(normalizedVersion)
return freshConfig
}
export async function resetAppAndRefreshConfig(
fetchConfig: () => Promise<AppConfig>,
version?: string | null
) {
const normalizedVersion = getNormalizedVersion(version)
clearCachedAppConfig()
clearAppStorageExceptToken()
clearAllBootstrapFetchCaches()
await clearBrowserRuntimeCaches()
const freshConfig = await fetchConfig()
writeCachedAppConfig(freshConfig)
if (normalizedVersion) {
writeStoredAppVersion(normalizedVersion)
}
return freshConfig
}
+92
View File
@@ -0,0 +1,92 @@
import type { AppConfig } from '@/config/types'
import { readCachedAppConfig, writeCachedAppConfig } from '@/config/config-cache'
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
function getProtocol() {
return window.location.protocol === 'https:' ? 'https:' : 'http:'
}
export function getConfigUrls() {
const protocol = getProtocol()
return [
`${protocol}//nerkh.gold/get_user`,
`${protocol}//tablo.talasevin.ir/get_user`,
`${protocol}//abshode-service.montgit.ir/server/get_user`,
`${protocol}//212.23.201.173:8381/get_user`,
`${protocol}//91.212.174.137:4040/server/get_user`
]
}
export function sortConfigUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
return [...urls].sort((a, b) => {
if (a === lastSuccessUrl) return -1
if (b === lastSuccessUrl) return 1
return 0
})
}
async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
},
cache: 'no-store',
signal: controller.signal
})
if (!response.ok) {
throw new Error(`Config request failed with status ${response.status}`)
}
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
throw new Error('Config response is not JSON')
}
return await response.json()
} finally {
clearTimeout(timeoutId)
}
}
export async function fetchFreshAppConfig(): Promise<AppConfig> {
const urls = sortConfigUrls(getConfigUrls())
for (let index = 0; index < urls.length; index++) {
const url = urls[index]
try {
const timeout = index === urls.length - 1 ? 5000 : 2500
const config = await fetchConfig(url, timeout)
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
writeCachedAppConfig(config)
return config
} catch (error) {
console.warn(`Config failed from ${url}`, error)
}
}
throw new Error('Cannot load app config from any server')
}
export async function loadAppConfig(): Promise<AppConfig> {
const cachedConfig = readCachedAppConfig()
if (cachedConfig) {
return cachedConfig
}
return await fetchFreshAppConfig()
}
+167
View File
@@ -0,0 +1,167 @@
type VersionResponse =
| string
| number
| {
version?: string | number
data?: string | number | { version?: string | number; value?: string | number }
value?: string | number
user?: unknown
}
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
const VERSION_RETRY_ROUNDS = 5
let versionFetchPromise: Promise<string | null> | null = null
let cachedVersionResult: string | null | undefined
function getVersionUrls() {
return [
'https://nerkh.gold/get_version',
'https://tablo.talasevin.ir/get_version',
'https://abshode-service.montgit.ir/server/get_version'
]
}
function sortVersionUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
if (!lastSuccessUrl) {
return urls
}
const others = urls.filter((url) => url !== lastSuccessUrl)
return [lastSuccessUrl, ...others]
}
function readUserVersion(user: unknown) {
if (!user || typeof user !== 'object' || Array.isArray(user)) {
return ''
}
const record = user as { version?: string | number; value?: string | number }
const version = record.version ?? record.value
return version === undefined || version === null ? '' : String(version).trim()
}
function normalizeVersionResponse(response: VersionResponse) {
if (typeof response === 'string' || typeof response === 'number') {
return String(response).trim()
}
if (!response) return ''
if (typeof response.data === 'string' || typeof response.data === 'number') {
return String(response.data).trim()
}
const directVersion =
response.version ??
response.value ??
(typeof response.data === 'object' ? response.data?.version ?? response.data?.value : undefined)
if (directVersion !== undefined && directVersion !== null) {
return String(directVersion).trim()
}
return readUserVersion(response.user)
}
async function fetchVersion(url: string, timeout = 3500): Promise<string | null> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
},
cache: 'no-store',
signal: controller.signal
})
if (!response.ok) {
// Non-2xx/timeout should be treated as "request failed" and we may try the next URL.
throw new Error(`Version request failed with status ${response.status}`)
}
const contentType = response.headers.get('content-type') ?? ''
const data = contentType.includes('application/json')
? ((await response.json()) as VersionResponse)
: ((await response.text()) as VersionResponse)
const version = normalizeVersionResponse(data)
// IMPORTANT: if the request returned 200/ok, we stop here even if we couldn't extract a version.
// This prevents unnecessary looping when the endpoint is "reachable" but returns an empty payload.
if (!version) {
return null
}
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
return version
} finally {
window.clearTimeout(timeoutId)
}
}
async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<string | null> {
const urls = sortVersionUrls(getVersionUrls())
for (let round = 1; round <= rounds; round++) {
for (let index = 0; index < urls.length; index++) {
const url = urls[index]
const timeout = index === urls.length - 1 ? 6000 : 3500
try {
const version = await fetchVersion(url, timeout)
// fetchVersion only throws on real request failures (non-ok / timeout).
// If it returns null, we got HTTP 200 but empty version => stop immediately.
return version
} catch (error) {
console.warn(`Version failed from ${url}. Round: ${round}`, error)
}
}
}
return null
}
export function resetVersionCheckCache() {
cachedVersionResult = undefined
versionFetchPromise = null
}
export async function loadRemoteAppVersion(options?: {
rounds?: number
force?: boolean
}): Promise<string | null> {
const rounds = options?.rounds ?? VERSION_RETRY_ROUNDS
const force = options?.force ?? false
if (force) {
resetVersionCheckCache()
}
if (cachedVersionResult !== undefined) {
return cachedVersionResult
}
if (versionFetchPromise) {
return versionFetchPromise
}
versionFetchPromise = fetchVersionWithRetry(rounds)
.then((version) => {
cachedVersionResult = version
return version
})
.finally(() => {
versionFetchPromise = null
})
return versionFetchPromise
}
+20
View File
@@ -0,0 +1,20 @@
export interface AppConfig {
mode?: string
theme?: string
name?: string
name_fa?: string
avatar?: string
color?: string
background_color?: string
secondary_color?: string
node_server?: string
node_ws?: string
manifest_url?: string
user?: Record<string, any>
[key: string]: any
}
+15 -185
View File
@@ -1,191 +1,21 @@
import 'ant-design-vue/dist/antd.less'
import './assets/css/main.less'
import 'virtual:uno.css'
import { computed, createApp, ref, watch } from 'vue'
import { createPinia } from 'pinia'
import '@unocss/reset/tailwind-compat.css'
import VueApexCharts from 'vue3-apexcharts'
import App from './App.vue'
import router from './router'
import { authenticateWithFingerprint, getToken, isIphone, removeToken } from '@/utils'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useFundUserStore } from '@/store/fundStore'
import VueLazyLoad from 'vue3-lazyload'
import { getMe } from '@/api/me'
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
import { useAuthStore } from '@/store/useAuthStore'
import { initPwaInstallCapture } from '@/modules/pwa/manifest'
import { bootstrapApp } from '@/app/bootstrap'
import { initWhatsappButtonDrag } from '@/utils/initWhatsappButtonDrag'
const app = createApp(App)
app.use(createPinia())
app.use(VueLazyLoad, {
// options...
export {
setTruth,
setEnableAuthenticate,
setFingerPosition,
setFingerTimeOut
} from '@/app/authBoot'
import '@unocss/reset/tailwind-compat.css'
initPwaInstallCapture()
bootstrapApp().then(() => {
initWhatsappButtonDrag()
})
app.use(router)
app.use(VueApexCharts)
const userStore = useUserStore()
const walletStore = useWalletStore()
const fundStore = useFundUserStore()
const authStore = useAuthStore()
const isApple: boolean = isIphone()
let isFirst: boolean = true
let fingerPosition: boolean = false
let timeoutId: ReturnType<typeof setTimeout> | null = null
let hasFingerPrint: boolean = false
const userInfo = ref()
// userInfo.value = await getMe()
// const token = getToken()
const currentRoute = computed(() => {
return router.currentRoute.value.fullPath
})
async function start() {
// console.log('ffffffffffffffffffffffffffffffffffffffffff')
if (getToken()) {
// await userStore.getMyInfo()
await walletStore.getLimitedPrice()
await walletStore.getLimitedBuyGold()
await fundStore.getMyFund()
}
}
export function setTruth() {
fingerPosition = true
}
export function setEnableAuthenticate() {
fingerPosition = false
}
function goToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) {
if (to.name === 'buyProduct'||to.name === 'productDetail'||to.name==='about'||to.name==='cart') {
return next() // Allow access to buyProduct without login
}
if (to.name !== 'login') {
return next({ name: 'login' }) // Redirect to login
} else {
return next() // If already on the login page, don't redirect
}
}
function goToHome(to: RouteLocationNormalized, next: NavigationGuardNext) {
if (to.name !== 'buyProduct') {
return next({ name: 'buyProduct' }) // Redirect to login
} else {
return next() // If already on the login page, don't redirect
}
}
router.beforeEach(async (to, from, next) => {
authStore.setLoading(true)
// console.log(authStore)
if (timeoutId) {
clearTimeout(timeoutId)
}
// Start a new timeout to reset fingerprint after 5 seconds of inactivity
if (from.name !== 'login' || from.name !== undefined) {
// console.log(from.name)
timeoutId = setTimeout(async () => {
if (hasFingerPrint) {
fingerPosition = false
/* fingerPosition = await authenticateWithFingerprint()
if (!fingerPosition) {
goToLogin(to, next)
}*/
// isFirst = true;
console.log('Fingerprint authentication reset after 5 seconds of inactivity')
}
}, 180000) // 5 seconds
}
// If it's the first time the navigation guard runs
if (isFirst) {
isFirst = false // Set isFirst to false after the first check
// If the user has a token
if (getToken()) {
await userStore.getMyInfo()
const finger_print =userStore.userInfo.finger_print
// console.log(finger_print)
// const { finger_print } = await getMe()
hasFingerPrint = finger_print
// If on an Apple device and fingerprint exists
if (isApple && finger_print) {
removeToken() // Remove token if fingerprint is found
goToLogin(to, next) // Redirect to login
}
// If not an Apple device and fingerprint exists
else if (!isApple && finger_print) {
// goToLogin(to, next)
fingerPosition = await authenticateWithFingerprint()
if (fingerPosition) {
// goToHome(to, next) // Redirect to home if fingerprint is authenticated
} else {
goToLogin(to, next) // Redirect to login if authentication fails
}
} else {
// console.log('salam')
if (from.path === '/') {
// goToHome(to, next)
}
if(to.name=="login"){
goToHome(to, next)
}
return next()
}
} else {
// If no token, handle redirect to login or allow access to login, 404, or buyProduct
if (['login', '404', 'buyProduct','productDetail','about','cart'].includes(to.name as string)) {
return next() // Allow access to login, 404, or buyProduct
}
goToLogin(to, next) // Redirect to login if no token
}
}
// For subsequent navigations after the first check
else {
// If the user has passed fingerprint authentication
if (fingerPosition) {
if (getToken()) {
if (to.name === 'login') {
return next({ name: from.name || 'buyProduct' }) // Redirect to home if trying to access login after authentication
}
return next() // Proceed to the intended route
} else {
// If no token, ensure proper redirection to login or 404
if (['login', '404', 'buyProduct','productDetail','about','cart'].includes(to.name as string)) {
return next() // Allow access to login, 404, or buyProduct
}
return next({ name: 'login' })
}
}
// If fingerprint authentication failed or was not done
else {
if (getToken() && !hasFingerPrint) {
next()
} else {
goToLogin(to, next)
}
/* if (to.name !== 'login') {
return next({ name: 'login' }) // Redirect to login
} else {
return next() // If already on the login page, don't redirect
}*/
/* console.log('sssssssssssssssssssssssss')
return next({ name: 'login' })*/
}
}
})
app.mount('#app')
initWhatsappButtonDrag()
// if (currentRoute.value !== '/') {
// await start()
// }
watch(currentRoute, async () => userStore.userInfo ? '' : await start())
+72
View File
@@ -0,0 +1,72 @@
export type PwaManifestIcon = {
src: string
sizes?: string
type?: string
purpose?: string
}
export type PwaManifest = {
id?: string
name?: string
short_name?: string
lang?: string
dir?: string
start_url?: string
scope?: string
display?: string
background_color?: string
theme_color?: string
description?: string
icons?: PwaManifestIcon[]
}
const MANIFEST_CACHE_KEY = 'PWA_MANIFEST_CACHE_V5'
const MANIFEST_CACHE_MAX_AGE_MS = 60 * 60 * 24 * 7
export type CachedPwaManifest = {
cached_at: number
user_id: string | number
config_base: string
manifest_url: string
manifest: PwaManifest
}
export function readCachedPwaManifest(
userId: string | number | null | undefined,
configBase: string
): CachedPwaManifest | null {
if (userId == null || userId === '' || !configBase) return null
try {
const raw = localStorage.getItem(MANIFEST_CACHE_KEY)
if (!raw) return null
const payload = JSON.parse(raw) as CachedPwaManifest
if (!payload?.manifest || !payload.manifest_url) return null
if (String(payload.user_id) !== String(userId)) return null
if (payload.config_base !== configBase) return null
if (Date.now() - Number(payload.cached_at || 0) > MANIFEST_CACHE_MAX_AGE_MS) {
localStorage.removeItem(MANIFEST_CACHE_KEY)
return null
}
return payload
} catch {
localStorage.removeItem(MANIFEST_CACHE_KEY)
return null
}
}
export function writeCachedPwaManifest(entry: CachedPwaManifest) {
try {
localStorage.setItem(MANIFEST_CACHE_KEY, JSON.stringify(entry))
} catch (error) {
console.warn('Failed to cache PWA manifest:', error)
}
}
export function clearCachedPwaManifest() {
localStorage.removeItem(MANIFEST_CACHE_KEY)
}
+558
View File
@@ -0,0 +1,558 @@
import type { AppConfig } from '@/config/types'
import { getConfig, getRawAppConfig } from '@/config/app-config'
import { getConfigUrls, sortConfigUrls } from '@/config/load-config'
import {
clearCachedPwaManifest,
readCachedPwaManifest,
writeCachedPwaManifest,
type PwaManifest
} from '@/modules/pwa/manifest-cache'
export { clearCachedPwaManifest, type PwaManifest, type PwaManifestIcon } from '@/modules/pwa/manifest-cache'
export const PWA_MANIFEST_READY_KEY = 'pwa_manifest_ready'
export const PWA_MANIFEST_READY_EVENT = 'pwa-manifest-ready'
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
let pendingBeforeInstallPrompt: Event | null = null
let installCaptureInitialized = false
const MANIFEST_RETRY_DELAY_MS = 500
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function normalizeBaseUrl(url: string) {
return url.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
}
/** همان baseای که get_user از آن لود شده (کش شده در localStorage) */
export function getConfigBaseFromStorage() {
const lastSuccess = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
if (!lastSuccess) return ''
return normalizeBaseUrl(lastSuccess)
}
function configUrlToManifestUrl(configUrl: string) {
return `${configUrl.replace(/\/+$/, '')}/manifest.json`
}
/** همان ۵ URL پایهٔ get_user — اولویت با CONFIG_Success_URL */
export function resolveManifestUrls(config?: AppConfig | null): string[] {
const explicit = getConfig<string>('manifest_url')
if (explicit) return [explicit]
return sortConfigUrls(getConfigUrls()).map(configUrlToManifestUrl)
}
/** اولین کاندید — برای سازگاری */
export function resolveManifestUrl(config?: AppConfig | null) {
return resolveManifestUrls(config)[0] ?? ''
}
function isValidManifest(payload: unknown): payload is PwaManifest {
if (!payload || typeof payload !== 'object') return false
const manifest = payload as PwaManifest
const icons = manifest.icons
if (!Array.isArray(icons) || icons.length === 0) return false
return icons.some((icon) => typeof icon?.src === 'string' && icon.src.trim().length > 0)
}
async function fetchManifestOnce(url: string, timeout = 6000): Promise<PwaManifest> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/manifest+json, application/json' },
cache: 'default',
signal: controller.signal,
priority: 'high'
})
if (!response.ok) {
throw new Error(`Manifest request failed: ${response.status}`)
}
const payload = await response.json()
if (!isValidManifest(payload)) {
throw new Error('Manifest response is missing valid icons')
}
return payload
} finally {
clearTimeout(timeoutId)
}
}
/** هر URL یک بار — مثل لوپ get_user */
async function fetchManifestTryingUrls(
urls: string[]
): Promise<{ manifest: PwaManifest; manifestUrl: string }> {
if (!urls.length) {
throw new Error('No manifest URL candidates')
}
let lastError: unknown = null
for (let index = 0; index < urls.length; index++) {
const url = urls[index]
try {
const manifest = await fetchManifestOnce(url)
return { manifest, manifestUrl: url }
} catch (error) {
lastError = error
console.warn(`PWA manifest failed (${url}), candidate ${index + 1}/${urls.length}`, error)
}
if (index < urls.length - 1) {
await sleep(MANIFEST_RETRY_DELAY_MS)
}
}
throw lastError ?? new Error('PWA manifest could not be loaded from any server')
}
let manifestBlobUrl: string | null = null
function revokeManifestBlobUrl() {
if (manifestBlobUrl) {
URL.revokeObjectURL(manifestBlobUrl)
manifestBlobUrl = null
}
}
function isAbsoluteWebUrl(src: string) {
return /^https?:\/\//i.test(src) || src.startsWith('//')
}
function toAbsoluteAssetUrl(src: string, base: string) {
const value = String(src || '').trim()
if (!value) return ''
if (value.startsWith('//')) {
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'
return `${protocol}${value}`
}
if (/^(https?:\/\/|data:|blob:)/i.test(value)) return value
if (!base) return value
return `${base.replace(/\/+$/, '')}/${value.replace(/^\/+/, '')}`
}
/** path داخل همین اپ — دامنهٔ سرور (montpar.ir) نادیده گرفته می‌شود */
function toManifestPath(url: string | undefined, fallback = '/') {
const defaultPath = fallback.startsWith('/') ? fallback : `/${fallback}`
if (!url?.trim()) return defaultPath
try {
const trimmed = url.trim()
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
return trimmed
}
const parsed = new URL(trimmed)
const path = `${parsed.pathname || '/'}${parsed.search}${parsed.hash}`
if (parsed.origin !== window.location.origin) {
return path || '/'
}
return path || '/'
} catch {
return defaultPath
}
}
/**
* blob manifest فقط URL مطلق هم‌origin را می‌پذیرد (مثلاً http://192.168.x.x:3100/).
* مسیر نسبی مثل "/" → "property ignored, URL is invalid".
*/
function toSameOriginManifestUrl(url: string | undefined, fallbackPath = '/') {
const path = toManifestPath(url, fallbackPath)
return new URL(path, window.location.origin).href
}
function normalizeManifestId(id: string | undefined, startUrl: string) {
const value = String(id || '').trim()
if (!value || value === '/') return startUrl
try {
const parsed = new URL(value, window.location.origin)
if (parsed.origin === window.location.origin) return parsed.href
} catch {
// ignore
}
return startUrl
}
function guessMimeType(src: string) {
const lower = src.split('?')[0].toLowerCase()
if (lower.endsWith('.png')) return 'image/png'
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
if (lower.endsWith('.webp')) return 'image/webp'
return 'image/png'
}
function mapManifestIcons(manifest: PwaManifest, base: string) {
return (manifest.icons || [])
.map((icon) => {
const src = toAbsoluteAssetUrl(icon.src, base)
return {
src,
sizes: icon.sizes || '512x512',
type: guessMimeType(src),
purpose: icon.purpose || 'any'
}
})
.filter((icon) => isAbsoluteWebUrl(icon.src))
}
/** فقط آیکون‌های داخل monifist.json — بدون آواتار / pwa-icons / env */
function resolveInstallIcons(manifest: PwaManifest) {
const base = getConfigBaseFromStorage()
const mapped = mapManifestIcons(manifest, base)
if (!mapped.length) {
throw new Error('Manifest icons could not be resolved to absolute URLs')
}
return mapped
}
function prepareManifestForInstall(manifest: PwaManifest) {
const scopeUrl = toSameOriginManifestUrl(manifest.scope ?? manifest.start_url, '/')
const startUrl = toSameOriginManifestUrl(manifest.start_url, '/')
const icons = resolveInstallIcons(manifest)
const name = manifest.short_name || manifest.name
if (!name) {
throw new Error('Manifest is missing name/short_name')
}
if (!manifest.theme_color) {
throw new Error('Manifest is missing theme_color')
}
// start_url باید داخل scope باشد
const scopeBase = scopeUrl.endsWith('/') ? scopeUrl : `${scopeUrl}/`
const effectiveScope = startUrl.startsWith(scopeBase) ? scopeUrl : toSameOriginManifestUrl('/', '/')
return {
id: normalizeManifestId(manifest.id, startUrl),
name,
short_name: manifest.short_name || manifest.name || name,
lang: manifest.lang || 'fa',
dir: manifest.dir || 'rtl',
start_url: startUrl,
scope: effectiveScope,
display: manifest.display || 'standalone',
background_color: manifest.background_color || '#ffffff',
theme_color: manifest.theme_color,
description: manifest.description || name,
icons
}
}
function pickManifestIconUrl(manifest: PwaManifest, preferredSizes: number[]) {
const icons = manifest.icons ?? []
for (const size of preferredSizes) {
const target = `${size}x${size}`
const match = icons.find((icon) => String(icon.sizes || '').includes(target))
if (match?.src) return match.src
}
const sorted = [...icons].sort((a, b) => {
const aSize = Number.parseInt(String(a.sizes || '0'), 10) || 0
const bSize = Number.parseInt(String(b.sizes || '0'), 10) || 0
return bSize - aSize
})
return sorted[0]?.src || ''
}
function upsertLink(rel: string, attrs: Record<string, string>) {
const selectorParts = Object.entries(attrs)
.filter(([key]) => key !== 'href')
.map(([key, value]) => `[${key}="${value}"]`)
.join('')
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]${selectorParts}`)
if (!link) {
link = document.createElement('link')
link.rel = rel
document.head.appendChild(link)
}
Object.entries(attrs).forEach(([key, value]) => {
link!.setAttribute(key, value)
})
return link
}
function upsertMeta(name: string, content: string) {
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
if (!meta) {
meta = document.createElement('meta')
meta.name = name
document.head.appendChild(meta)
}
meta.content = content
}
function removeAllManifestLinks() {
document.querySelectorAll<HTMLLinkElement>("link[rel='manifest']").forEach((node) => node.remove())
}
function removeDynamicManifestLinks() {
removeAllManifestLinks()
revokeManifestBlobUrl()
document
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
.forEach((node) => node.remove())
}
function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
document
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
.forEach((node) => node.remove())
const appleSizes = [180, 167, 152, 120]
const used = new Set<string>()
appleSizes.forEach((size) => {
const href = pickManifestIconUrl(manifest, [size, 192, 512])
if (!href || used.has(href)) return
used.add(href)
upsertLink('apple-touch-icon', {
'data-dynamic-pwa': 'true',
sizes: `${size}x${size}`,
href
})
})
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
if (primary) {
upsertLink('apple-touch-icon', {
'data-dynamic-pwa': 'true',
href: primary
})
}
}
export async function applyPreparedManifestToDocument(
normalized: PwaManifest,
manifestUrl: string
) {
removeDynamicManifestLinks()
if (!normalized.icons?.length) {
throw new Error('Manifest has no valid absolute icon URLs')
}
const title = normalized.short_name || normalized.name
if (!title) {
throw new Error('Prepared manifest is missing title')
}
document.title = title
const blob = new Blob([JSON.stringify(normalized)], {
type: 'application/manifest+json'
})
manifestBlobUrl = URL.createObjectURL(blob)
upsertLink('manifest', {
'data-dynamic-pwa': 'true',
href: manifestBlobUrl
})
if (normalized.theme_color) {
upsertMeta('theme-color', normalized.theme_color)
}
upsertMeta('apple-mobile-web-app-title', title)
upsertMeta('mobile-web-app-capable', 'yes')
upsertMeta('apple-mobile-web-app-capable', 'yes')
applyAppleTouchIconsFromManifest(normalized)
const favicon = pickManifestIconUrl(normalized, [192, 180, 128, 96, 72])
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
if (favicon) {
upsertLink('icon', { type: 'image/png', href: favicon })
}
}
export async function applyManifestToDocument(manifest: PwaManifest, manifestUrl: string) {
const prepared = prepareManifestForInstall(manifest)
await applyPreparedManifestToDocument(prepared, manifestUrl)
}
export function setPwaManifestReady(enabled: boolean) {
if (enabled) {
sessionStorage.setItem(PWA_MANIFEST_READY_KEY, '1')
} else {
sessionStorage.removeItem(PWA_MANIFEST_READY_KEY)
}
}
export function isPwaManifestReady() {
return sessionStorage.getItem(PWA_MANIFEST_READY_KEY) === '1'
}
/** هرچه زودتر در main.ts — قبل از bootstrap */
export function initPwaInstallCapture() {
if (installCaptureInitialized || typeof window === 'undefined') return
installCaptureInitialized = true
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
if (isPwaManifestReady()) {
notifyPwaManifestReady(e)
return
}
pendingBeforeInstallPrompt = e
console.info('PWA install prompt captured, waiting for manifest + service worker')
})
}
export function notifyPwaManifestReady(installEvent?: Event | null) {
if (!isPwaManifestReady()) return
const event = installEvent ?? pendingBeforeInstallPrompt
window.dispatchEvent(
new CustomEvent(PWA_MANIFEST_READY_EVENT, {
detail: { installEvent: event }
})
)
if (!event) {
console.info('PWA ready (manifest + SW). Waiting for beforeinstallprompt…', {
secureContext: window.isSecureContext,
origin: window.location.origin
})
}
pendingBeforeInstallPrompt = null
}
async function applyCachedManifestEntry(
cached: ReturnType<typeof readCachedPwaManifest>
): Promise<boolean> {
if (!cached) return false
try {
const prepared = prepareManifestForInstall(cached.manifest)
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
setPwaManifestReady(true)
return true
} catch (error) {
console.warn('Cached PWA manifest invalid, refetching:', error)
clearCachedPwaManifest()
return false
}
}
/**
* بعد از loadAppConfig / setAppConfig صدا بزن.
* get_user دوباره fetch نمی‌شود — manifest از همان ۵ URL پایهٔ get_user امتحان می‌شود.
*/
export async function setupPwaManifest(config?: AppConfig | null): Promise<boolean> {
const raw = config ?? getRawAppConfig()
const userId = raw?.user?.id
const configBase = getConfigBaseFromStorage()
const manifestUrls = resolveManifestUrls(raw)
if (!manifestUrls.length) {
console.warn('PWA manifest skipped: no manifest URL candidates')
removeDynamicManifestLinks()
setPwaManifestReady(false)
return false
}
const cached = configBase ? readCachedPwaManifest(userId, configBase) : null
if (cached && (await applyCachedManifestEntry(cached))) {
console.info('PWA manifest applied from cache:', cached.manifest_url)
return true
}
try {
const { manifest, manifestUrl } = await fetchManifestTryingUrls(manifestUrls)
const prepared = prepareManifestForInstall(manifest)
await applyPreparedManifestToDocument(prepared, manifestUrl)
setPwaManifestReady(true)
if (userId != null && userId !== '') {
const effectiveConfigBase =
configBase || normalizeBaseUrl(manifestUrl.replace(/\/manifest\.json$/i, ''))
writeCachedPwaManifest({
cached_at: Date.now(),
user_id: userId,
config_base: effectiveConfigBase,
manifest_url: manifestUrl,
manifest: prepared
})
}
console.info('PWA manifest loaded once:', manifestUrl)
return true
} catch (error) {
console.warn('PWA manifest unavailable, PWA disabled this session:', error)
removeDynamicManifestLinks()
setPwaManifestReady(false)
return false
}
}
let manifestBootstrapStarted = false
/**
* بلافاصله بعد از setAppConfig — بدون await در bootstrap.
* اپ منتظر مانیفست نمی‌ماند؛ SW بعد از موفقیت در پس‌زمینه ثبت می‌شود.
*/
export function startPwaManifest(config?: AppConfig | null) {
if (manifestBootstrapStarted) return
manifestBootstrapStarted = true
void setupPwaManifest(config).then((ready) => {
if (!ready) return
void import('@/modules/pwa/register-pwa').then(({ registerAppPwa }) => registerAppPwa())
})
}
+33
View File
@@ -0,0 +1,33 @@
import { registerSW } from 'virtual:pwa-register'
import { isPwaManifestReady, notifyPwaManifestReady } from '@/modules/pwa/manifest'
let swRegistrationStarted = false
export function registerAppPwa() {
if (!isPwaManifestReady()) {
console.info('Skipping service worker registration: manifest not ready')
return
}
if (swRegistrationStarted) return
swRegistrationStarted = true
registerSW({
immediate: true,
onRegistered() {
console.info('PWA service worker registered')
notifyPwaManifestReady()
},
onRegisterError(error: unknown) {
console.warn('PWA service worker registration failed:', error)
notifyPwaManifestReady()
},
onNeedRefresh() {
console.log('New content available, please refresh.')
},
onOfflineReady() {
console.log('App ready to work offline.')
notifyPwaManifestReady()
}
})
}
+223
View File
@@ -0,0 +1,223 @@
<template>
<Teleport to="body">
<Transition name="version-update-fade">
<div v-if="isUpdating || hasError" class="version-update-overlay" dir="rtl">
<div class="version-update-card">
<div v-if="!hasError" class="version-update-spinner" />
<div v-else class="version-update-error-icon">!</div>
<h2 class="version-update-title">
{{ hasError ? 'خطا در به‌روزرسانی' : 'در حال به‌روزرسانی برنامه' }}
</h2>
<p class="version-update-text">
{{ statusText || 'لطفاً چند لحظه صبر کنید...' }}
</p>
<button v-if="hasError" type="button" class="version-update-button" @click="retry">
تلاش مجدد
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { setAppConfig } from '@/config/app-config'
import { fetchFreshAppConfig } from '@/config/load-config'
import { loadRemoteAppVersion, resetVersionCheckCache } from '@/config/load-version'
import {
shouldUpdateForVersion,
canStartVersionUpdate,
refreshConfigCacheForVersion,
cancelVersionUpdateLock,
hardReloadApp
} from '@/config/config-cache'
const isUpdating = ref(false)
const hasError = ref(false)
const statusText = ref('')
const pendingVersion = ref('')
onMounted(async () => {
await checkRemoteVersion()
})
async function checkRemoteVersion() {
try {
const version = await loadRemoteAppVersion()
/**
* خیلی مهم:
* اگر get_version جواب نداد، هیچ کاری نکن.
* نه cache پاک کن، نه reload بزن.
* چون ممکنه کاربر فقط اینترنت ضعیف داشته باشه یا endpoint موقتاً 404/timeout بده.
*/
if (!version) {
console.warn('Remote version is empty. Version check skipped.')
return
}
if (!shouldUpdateForVersion(version)) return
if (!canStartVersionUpdate(version)) return
pendingVersion.value = version
await startUpdate(version)
} catch (error) {
console.warn('Version check failed. Version update skipped:', error)
}
}
async function startUpdate(version: string) {
try {
isUpdating.value = true
hasError.value = false
statusText.value = 'در حال آماده‌سازی نسخه جدید...'
await wait(500)
statusText.value = 'دریافت تنظیمات جدید برنامه...'
const freshConfig = await refreshConfigCacheForVersion(version, fetchFreshAppConfig)
setAppConfig(freshConfig)
await wait(500)
statusText.value = 'بارگذاری مجدد برنامه...'
await wait(300)
hardReloadApp()
} catch (error) {
console.error('Version update failed:', error)
cancelVersionUpdateLock()
isUpdating.value = false
hasError.value = true
statusText.value = 'به‌روزرسانی انجام نشد. لطفاً دوباره تلاش کنید.'
}
}
function retry() {
hasError.value = false
if (pendingVersion.value) {
startUpdate(pendingVersion.value)
return
}
resetVersionCheckCache()
void checkRemoteVersion()
}
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
</script>
<style scoped>
.version-update-overlay {
position: fixed;
inset: 0;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: radial-gradient(
circle at top,
rgba(var(--colorPrimaryRGB, 37 99 235) / 0.16),
transparent 38%
),
rgba(15, 23, 42, 0.38);
backdrop-filter: blur(8px);
}
.version-update-card {
width: min(390px, 100%);
padding: 30px 22px 26px;
border-radius: 24px;
text-align: center;
color: var(--colorText, #111827);
background: var(--bg, #ffffff);
border: 1px solid rgba(var(--colorPrimaryRGB, 37 99 235) / 0.18);
box-shadow: 0 22px 70px rgba(15, 23, 42, 0.22);
}
.version-update-spinner {
width: 58px;
height: 58px;
margin: 0 auto 20px;
border-radius: 999px;
border: 4px solid rgba(var(--colorPrimaryRGB, 37 99 235) / 0.14);
border-top-color: var(--colorPrimary, #2563eb);
animation: version-update-spin 0.9s linear infinite;
}
.version-update-error-icon {
width: 58px;
height: 58px;
margin: 0 auto 20px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color6, #ffffff);
background: var(--color5, #f44336);
font-size: 34px;
font-weight: 900;
line-height: 1;
}
.version-update-title {
margin: 0;
color: var(--colorText, #111827);
font-size: 19px;
font-weight: 900;
}
.version-update-text {
margin: 12px 0 0;
color: var(--colorText, #111827);
opacity: 0.72;
font-size: 14px;
line-height: 2;
}
.version-update-button {
width: 100%;
margin-top: 22px;
padding: 12px 18px;
border: 0;
border-radius: 14px;
color: var(--color6, #ffffff);
background: var(--colorPrimary, #2563eb);
font-weight: 800;
cursor: pointer;
}
.version-update-button:active {
transform: translateY(1px);
}
.version-update-fade-enter-active,
.version-update-fade-leave-active {
transition: opacity 0.2s ease;
}
.version-update-fade-enter-from,
.version-update-fade-leave-to {
opacity: 0;
}
@keyframes version-update-spin {
to {
transform: rotate(360deg);
}
}
</style>
+54
View File
@@ -0,0 +1,54 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { getSettings } from '@/api/configuration'
export interface AppSetting {
id?: number
key?: string | number
title_fa?: string
value?: string | number
description?: string
is_active?: boolean
type?: string
}
export const useSettingsStore = defineStore('settings', () => {
const settings = ref<AppSetting[]>([])
const loaded = ref(false)
let fetchPromise: Promise<AppSetting[]> | null = null
function getSettingByKey(key: string) {
const item = settings.value.find((setting) => setting?.key === key)
return item?.value ?? null
}
async function fetchSettings(force = false) {
if (!force && fetchPromise) {
return fetchPromise
}
fetchPromise = (async () => {
try {
const data = await getSettings()
if (Array.isArray(data)) {
settings.value = data
}
} catch (error) {
console.log(error)
} finally {
loaded.value = true
}
return settings.value
})()
return fetchPromise
}
return {
settings,
loaded,
fetchSettings,
getSettingByKey,
}
})
+8
View File
@@ -0,0 +1,8 @@
export function getConfigApp(key: string) {
const config = window.__APP_CONFIG__ ? window.__APP_CONFIG__.user : {}
if (config) {
return config[key]
}
return null
}
+150 -70
View File
@@ -1,86 +1,166 @@
import {loading} from '@/utils/index'
import {getLastPrice, getPrice} from '@/api/home'
import {ref} from 'vue'
import type {Price} from '@/models/home'
import { getLastPrice, getPrice } from '@/api/home'
import type { Price, PriceUp } from '@/models/home'
import { ref, watch } from 'vue'
import {
priceSocketConnected,
priceStreamLifecycle,
priceWssEnabled,
shouldUsePricePolling,
} from '@/utils/priceSocketState'
const LAST_INTERVAL_MS = 10000
let lastInterval: ReturnType<typeof setInterval> | undefined
let lastSubscribers = 0
let interval: any
export const price = ref<Price>()
export const lastPrice = ref<Price>()
export const olderPrice = ref<number| undefined>(0)
export const olderPrice = ref<number | undefined>(0)
let lastPriceSourceAt = 0
let consecutivePriceFetchFailures = 0
function hasValidPricePayload(data?: Price | null): boolean {
if (!data) {
return false
}
return (
Number(data.buy ?? data.up?.buy ?? 0) > 0
|| Number(data.sell ?? data.up?.sell ?? 0) > 0
|| Number(data.price_last ?? data.price ?? 0) > 0
)
}
export function markPriceSourceReceived() {
lastPriceSourceAt = Date.now()
consecutivePriceFetchFailures = 0
}
export function markPriceSourceFetchFailed() {
consecutivePriceFetchFailures += 1
}
export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean {
return lastPriceSourceAt > 0 && Date.now() - lastPriceSourceAt < maxAgeMs
}
export function consecutivePriceSourceFailures(): number {
return consecutivePriceFetchFailures
}
function startPollingInterval() {
if (lastInterval || !shouldUsePricePolling()) {
return
}
lastInterval = setInterval(() => {
if (shouldUsePricePolling()) {
void getLastPrices()
}
}, LAST_INTERVAL_MS)
}
function stopPollingInterval() {
if (lastInterval) {
clearInterval(lastInterval)
lastInterval = undefined
}
}
function syncLastPricePolling() {
const usePolling = shouldUsePricePolling()
if (usePolling) {
if (lastSubscribers > 0) {
startPollingInterval()
}
return
}
stopPollingInterval()
}
watch(
() => [priceWssEnabled.value, priceSocketConnected.value, priceStreamLifecycle.value] as const,
() => {
syncLastPricePolling()
},
)
export function startLastPricePolling() {
lastSubscribers++
syncLastPricePolling()
}
export async function setIntervalGetAllPrice() {
// console.log('salam')
try {
interval = setInterval(async () => {
await getLastPrices()
await getUpPrice()
}, 10000)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
startLastPricePolling()
}
export async function setIntervalGetLastPrice() {
// console.log('salam')
try {
interval = setInterval(async () => {
await getLastPrices()
// await getUpPrice()
}, 10000)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
startLastPricePolling()
}
export async function getUpPrice() {
try {
// loading.value = true
const data = await getPrice()
// const dataLast = await getLastPrice()
// if (lastPrice.value?.price_last != dataLast.price_last) {
// olderPrice.value = lastPrice.value?.price_last
// }
price.value = data
// lastPrice.value = dataLast
} catch (e) {
console.log(e)
} finally {
// loading.value = false
}
}
export async function getLastPrices() {
try {
// loading.value = true
// const data = await getPrice()
const dataLast = await getLastPrice()
if (lastPrice.value?.price_last != dataLast.price_last) {
olderPrice.value = lastPrice.value?.price_last
}
// price.value = data
lastPrice.value = dataLast
} catch (e) {
console.log(e)
} finally {
// loading.value = false
}
export function stopLastPricePolling() {
lastSubscribers = Math.max(0, lastSubscribers - 1)
if (lastSubscribers === 0) {
stopPollingInterval()
}
}
export function stopPriceInterval() {
// console.log('stop')
clearInterval(interval)
stopLastPricePolling()
}
async function fetchUpPrices(): Promise<PriceUp | undefined> {
try {
const up = await getPrice()
if (up?.buy != null && up?.sell != null) {
return { buy: up.buy, sell: up.sell }
}
} catch (error) {
console.log(error)
}
}
export async function getUpPrice() {
await getLastPrices()
}
export async function getLastPrices(): Promise<boolean> {
try {
let dataLast = await getLastPrice()
if (!dataLast?.up) {
const up = await fetchUpPrices()
if (up) {
dataLast = { ...dataLast, up }
}
}
applyLastPricePayload(dataLast)
return hasValidPricePayload(dataLast)
} catch (error) {
console.log(error)
markPriceSourceFetchFailed()
return false
}
}
export function applyLastPricePayload(dataLast: Price) {
if (lastPrice.value?.price_last != dataLast.price_last) {
olderPrice.value = lastPrice.value?.price_last
}
lastPrice.value = dataLast
price.value = dataLast
if (hasValidPricePayload(dataLast)) {
markPriceSourceReceived()
}
}
export function percentPriceChanged() {
// console.log(olderPrice.value)
// console.log(lastPrice.value?.price_last)
if (!olderPrice.value)
return 0
return ((lastPrice.value?.price_last-olderPrice.value) *100)/olderPrice.value
}
if (!olderPrice.value) {
return 0
}
return ((lastPrice.value?.price_last! - olderPrice.value) * 100) / olderPrice.value
}
+630
View File
@@ -0,0 +1,630 @@
import type { Price } from '@/models/home'
import type { Product } from '@/models/products'
import { Modal } from 'ant-design-vue'
import { getConfigApp } from '@/utils/config'
import { applyProductsListFromStream } from '@/utils/productStreamBridge'
import {
priceSocketConnected,
priceStreamConnectingSince,
priceStreamLifecycle,
priceWssEnabled,
} from '@/utils/priceSocketState'
export {
priceSocketConnected,
priceStreamConnectingSince,
priceStreamLifecycle,
priceWssEnabled,
shouldUsePricePolling,
} from '@/utils/priceSocketState'
const RECONNECT_BASE_MS = 3000
const RECONNECT_MAX_MS = 30000
const FAILURES_BEFORE_ALERT = 4
const ALERT_COOLDOWN_MS = 5 * 60 * 1000
const FALLBACK_POLL_INTERVAL_MS = 10000
const PRICE_SOURCE_STALE_MS_MOBILE = 50000
const PRICE_SOURCE_STALE_MS_DESKTOP = 35000
const STALE_STREAM_MS = 120000
const FALLBACK_POLL_DELAY_MS = 2500
let eventSource: EventSource | null = null
let subscribers = 0
let stopped = false
let getSetting: ((key: string) => unknown) | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
let staleCheckTimer: ReturnType<typeof setInterval> | undefined
let reconnectAttempt = 0
let lastStreamEventAt = 0
let networkAlertOpen = false
let lastNetworkAlertAt = 0
let listenersBound = false
let fallbackPollTimer: ReturnType<typeof setTimeout> | undefined
let fallbackPollInterval: ReturnType<typeof setInterval> | undefined
let pendingPriceAlert = false
function isMobileDevice(): boolean {
if (typeof navigator === 'undefined') {
return false
}
return /iPhone|iPad|iPod|Android|Mobile/i.test(navigator.userAgent)
}
function isPageVisible(): boolean {
return typeof document === 'undefined' || document.visibilityState === 'visible'
}
function markConnectingLifecycle() {
priceStreamLifecycle.value = 'connecting'
if (priceStreamConnectingSince.value <= 0) {
priceStreamConnectingSince.value = Date.now()
}
}
function clearConnectingLifecycle() {
priceStreamConnectingSince.value = 0
}
function isTruthySetting(value: unknown, defaultValue = true): boolean {
if (value === null || value === undefined || value === '') {
return defaultValue
}
return ['true', '1', 'yes', 'on'].includes(String(value).toLowerCase())
}
function resolveStreamPath(): string {
const ssePath = String(getSetting?.('sse_path') ?? '').trim()
if (ssePath && !ssePath.includes('/prc/')) {
return ssePath.startsWith('/') ? ssePath : `/${ssePath}`
}
const legacyPath = String(getSetting?.('wss_path') ?? '').trim()
if (legacyPath && !legacyPath.includes('/prc/')) {
return legacyPath.startsWith('/') ? legacyPath : `/${legacyPath}`
}
return '/api/price/stream'
}
function resolveStreamUrl(): string | null {
if (!getSetting) {
return null
}
if (!isTruthySetting(getSetting('wss_enabled'), true)) {
return null
}
const base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '')
return `${base}${resolveStreamPath()}`
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = undefined
}
}
function stopStaleCheck() {
if (staleCheckTimer) {
clearInterval(staleCheckTimer)
staleCheckTimer = undefined
}
}
function touchStreamActivity() {
lastStreamEventAt = Date.now()
}
function startStaleCheck() {
stopStaleCheck()
touchStreamActivity()
staleCheckTimer = setInterval(() => {
if (
stopped
|| subscribers <= 0
|| !eventSource
|| eventSource.readyState !== EventSource.OPEN
) {
return
}
if (Date.now() - lastStreamEventAt > STALE_STREAM_MS) {
console.warn('Price SSE stale — reconnecting')
handleStreamLost(true)
}
}, 15000)
}
function clearFallbackPollTimer() {
if (fallbackPollTimer) {
clearTimeout(fallbackPollTimer)
fallbackPollTimer = undefined
}
}
function stopFallbackPolling() {
clearFallbackPollTimer()
if (fallbackPollInterval) {
clearInterval(fallbackPollInterval)
fallbackPollInterval = undefined
}
}
function startFallbackPolling() {
if (fallbackPollInterval) {
return
}
scheduleFallbackPoll()
fallbackPollInterval = setInterval(() => {
if (stopped || subscribers <= 0 || priceSocketConnected.value) {
stopFallbackPolling()
return
}
void runFallbackPoll()
}, FALLBACK_POLL_INTERVAL_MS)
}
function scheduleFallbackPoll() {
clearFallbackPollTimer()
fallbackPollTimer = setTimeout(() => {
fallbackPollTimer = undefined
if (!stopped && subscribers > 0 && !priceSocketConnected.value) {
void runFallbackPoll()
}
}, FALLBACK_POLL_DELAY_MS)
}
async function runFallbackPoll() {
if (!priceWssEnabled.value || priceSocketConnected.value) {
return
}
try {
const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import(
'@/utils/price'
)
const { getProducts } = await import('@/api/products')
const { applyProductsListFromStream: applyList } = await import('@/utils/productStreamBridge')
const [lastResult, products] = await Promise.allSettled([
getLastPrices(),
getProducts(),
])
if (products.status === 'fulfilled' && Array.isArray(products.value) && products.value.length) {
applyList(products.value)
}
const priceOk =
lastResult.status === 'fulfilled'
&& lastResult.value === true
&& hasRecentPriceFromSource(getPriceSourceStaleMs())
if (!priceOk) {
markPriceSourceFetchFailed()
}
void maybeShowNetworkAlert()
} catch (error) {
console.warn('Price fallback poll failed', error)
const { markPriceSourceFetchFailed } = await import('@/utils/price')
markPriceSourceFetchFailed()
void maybeShowNetworkAlert()
}
}
function getPriceSourceStaleMs(): number {
return isMobileDevice() ? PRICE_SOURCE_STALE_MS_MOBILE : PRICE_SOURCE_STALE_MS_DESKTOP
}
async function isRealPriceOutage(): Promise<boolean> {
const {
hasRecentPriceFromSource,
consecutivePriceSourceFailures,
} = await import('@/utils/price')
const staleMs = getPriceSourceStaleMs()
if (hasRecentPriceFromSource(staleMs)) {
pendingPriceAlert = false
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
}
function showNetworkAlert() {
if (networkAlertOpen || stopped || !priceWssEnabled.value) {
return
}
if (!isPageVisible()) {
pendingPriceAlert = true
return
}
const now = Date.now()
if (now - lastNetworkAlertAt < ALERT_COOLDOWN_MS) {
return
}
lastNetworkAlertAt = now
pendingPriceAlert = false
networkAlertOpen = true
Modal.confirm({
title: 'اتصال قیمت برقرار نیست',
content:
'به‌روزرسانی لحظه‌ای قیمت قطع شده است. اتصال اینترنت را بررسی کنید یا دوباره تلاش کنید. تا زمان اتصال مجدد، قیمت با تأخیر به‌روز می‌شود.',
okText: 'تلاش مجدد',
cancelText: 'بستن',
centered: true,
onOk: () => {
networkAlertOpen = false
reconnectAttempt = 0
forceReconnect()
},
onCancel: () => {
networkAlertOpen = false
},
})
}
async function maybeShowNetworkAlert() {
if (!(await isRealPriceOutage())) {
return
}
showNetworkAlert()
}
function scheduleReconnect() {
if (stopped || subscribers <= 0) {
return
}
clearReconnectTimer()
const delay = Math.min(
RECONNECT_BASE_MS * Math.pow(2, reconnectAttempt),
RECONNECT_MAX_MS,
)
reconnectTimer = setTimeout(() => {
reconnectAttempt += 1
connect()
maybeShowNetworkAlert()
}, delay)
}
async function applyLastFromStream(last: Price) {
const { applyLastPricePayload } = await import('@/utils/price')
applyLastPricePayload(last)
}
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) {
return
}
touchStreamActivity()
void applyLastFromStream(last)
}
function handleListPayload(payload: unknown) {
if (!Array.isArray(payload) || !payload.length) {
return
}
touchStreamActivity()
applyProductsListFromStream(payload as Product[])
}
function bindStreamEvents(source: EventSource) {
source.addEventListener('last', (event) => {
try {
handleLastPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Price SSE last parse failed', error)
}
})
source.addEventListener('list', (event) => {
try {
handleListPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Price SSE list parse failed', error)
}
})
source.addEventListener('ping', () => {
touchStreamActivity()
})
source.onmessage = () => {
touchStreamActivity()
}
}
function handleStreamLost(scheduleRetry = true) {
const wasConnected = priceSocketConnected.value
disconnectSourceOnly()
priceSocketConnected.value = false
priceStreamLifecycle.value = scheduleRetry ? 'connecting' : 'closed'
if ((wasConnected || scheduleRetry) && scheduleRetry) {
startFallbackPolling()
}
if (scheduleRetry && !stopped && subscribers > 0) {
scheduleReconnect()
}
}
function disconnectSourceOnly() {
stopStaleCheck()
if (eventSource) {
eventSource.onopen = null
eventSource.onerror = null
eventSource.onmessage = null
eventSource.close()
eventSource = null
}
}
function connect() {
if (stopped || subscribers <= 0) {
return
}
const url = resolveStreamUrl()
if (!url) {
priceSocketConnected.value = false
priceStreamLifecycle.value = 'closed'
clearConnectingLifecycle()
return
}
if (eventSource) {
const state = eventSource.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
return
}
disconnectSourceOnly()
}
markConnectingLifecycle()
try {
eventSource = new EventSource(url)
} catch (error) {
console.warn('Price SSE connect failed', error)
handleStreamLost()
return
}
bindStreamEvents(eventSource)
eventSource.onopen = () => {
reconnectAttempt = 0
clearReconnectTimer()
stopFallbackPolling()
networkAlertOpen = false
priceStreamLifecycle.value = 'open'
clearConnectingLifecycle()
priceSocketConnected.value = true
touchStreamActivity()
startStaleCheck()
}
eventSource.onerror = () => {
const state = eventSource?.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
return
}
handleStreamLost()
}
}
function disconnect() {
clearReconnectTimer()
stopFallbackPolling()
disconnectSourceOnly()
priceSocketConnected.value = false
priceStreamLifecycle.value = 'idle'
clearConnectingLifecycle()
}
export function forceReconnect() {
if (stopped || subscribers <= 0) {
return
}
clearReconnectTimer()
disconnect()
connect()
}
function onBrowserOnline() {
if (stopped || subscribers <= 0) {
return
}
reconnectAttempt = 0
networkAlertOpen = false
forceReconnect()
}
function onBrowserOffline() {
handleStreamLost(true)
void maybeShowNetworkAlert()
}
function bindBrowserNetworkListeners() {
if (listenersBound || typeof window === 'undefined') {
return
}
listenersBound = true
window.addEventListener('online', onBrowserOnline)
window.addEventListener('offline', onBrowserOffline)
}
function unbindBrowserNetworkListeners() {
if (!listenersBound || typeof window === 'undefined') {
return
}
listenersBound = false
window.removeEventListener('online', onBrowserOnline)
window.removeEventListener('offline', onBrowserOffline)
}
export function startPriceSocket(settingGetter: (key: string) => unknown) {
getSetting = settingGetter
subscribers += 1
stopped = false
connect()
bindBrowserNetworkListeners()
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibilityChange)
}
if (typeof window !== 'undefined') {
window.addEventListener('pageshow', onPageShow)
}
}
export function stopPriceSocket() {
subscribers = Math.max(0, subscribers - 1)
if (subscribers > 0) {
return
}
stopped = true
disconnect()
unbindBrowserNetworkListeners()
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange)
}
if (typeof window !== 'undefined') {
window.removeEventListener('pageshow', onPageShow)
}
}
function onVisibilityChange() {
if (stopped || subscribers <= 0) {
return
}
if (document.visibilityState === 'hidden') {
reconnectAttempt = 0
return
}
reconnectAttempt = 0
networkAlertOpen = false
if (pendingPriceAlert) {
void maybeShowNetworkAlert()
}
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
forceReconnect()
}
}
function onPageShow(event: PageTransitionEvent) {
if (stopped || subscribers <= 0) {
return
}
if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) {
reconnectAttempt = 0
networkAlertOpen = false
forceReconnect()
if (pendingPriceAlert) {
void maybeShowNetworkAlert()
}
}
}
export function isPriceWebSocketEnabled(settingGetter: (key: string) => unknown): boolean {
getSetting = settingGetter
if (!isTruthySetting(settingGetter('wss_enabled'), true)) {
return false
}
return resolveStreamUrl() !== null
}
let channelStarted = false
function shutdownPriceRealtimeChannel() {
if (!channelStarted && subscribers <= 0) {
return
}
channelStarted = false
stopped = true
subscribers = 0
disconnect()
unbindBrowserNetworkListeners()
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange)
}
if (typeof window !== 'undefined') {
window.removeEventListener('pageshow', onPageShow)
}
}
export function initPriceRealtimeChannel(settingGetter: (key: string) => unknown) {
getSetting = settingGetter
priceWssEnabled.value = isTruthySetting(settingGetter('wss_enabled'), true)
if (!priceWssEnabled.value || !isPriceWebSocketEnabled(settingGetter)) {
shutdownPriceRealtimeChannel()
return
}
if (channelStarted && subscribers > 0) {
forceReconnect()
return
}
channelStarted = true
stopped = false
startPriceSocket(settingGetter)
}
+33
View File
@@ -0,0 +1,33 @@
import { ref } from 'vue'
export const priceSocketConnected = ref(false)
export const priceWssEnabled = ref(false)
export type PriceStreamLifecycle = 'idle' | 'connecting' | 'open' | 'closed'
export const priceStreamLifecycle = ref<PriceStreamLifecycle>('idle')
export const priceStreamConnectingSince = ref(0)
const CONNECTING_POLL_AFTER_MS = 8000
export function shouldUsePricePolling(): boolean {
if (!priceWssEnabled.value) {
return true
}
if (priceStreamLifecycle.value === 'open') {
return false
}
if (priceStreamLifecycle.value === 'connecting') {
const since = priceStreamConnectingSince.value
if (since > 0 && Date.now() - since >= CONNECTING_POLL_AFTER_MS) {
return true
}
return false
}
return true
}
+21
View File
@@ -0,0 +1,21 @@
import type { Product } from '@/models/products'
type ProductListHandler = (products: Product[]) => void
const handlers = new Set<ProductListHandler>()
export function subscribeProductListStream(handler: ProductListHandler) {
handlers.add(handler)
return () => {
handlers.delete(handler)
}
}
export function applyProductsListFromStream(products: Product[]) {
if (!Array.isArray(products) || !products.length) {
return
}
handlers.forEach((handler) => handler(products))
}
+164
View File
@@ -0,0 +1,164 @@
import { ref } from 'vue'
import { useSettingsStore } from '@/store/settingsStore'
import { lastPrice } from '@/utils/price'
export interface StoreTradingHours {
start: string
end: string
closePriceEnabled: boolean
}
export interface EffectiveTradingHours {
start: string
end: string
enforced: boolean
}
const productTradingHours = ref<{ start: string; end: string } | null>(null)
const productTradingHoursVersion = ref(0)
function normalizeTradingTimeSetting(value: unknown, fallback: string): string {
const raw = String(value ?? '').trim()
return raw || fallback
}
function parseTimeToSeconds(time: string): number {
const parts = time.trim().split(':').map(Number)
const [hours = 0, minutes = 0, seconds = 0] = parts
return hours * 3600 + minutes * 60 + seconds
}
export function formatTradingTimeDisplay(time: string): string {
const normalized = normalizeTradingTimeSetting(time, '')
if (!normalized) {
return ''
}
const parts = normalized.split(':')
if (parts.length >= 3) {
return `${parts[0]}:${parts[1]}:${parts[2]}`
}
if (parts.length >= 2) {
return `${parts[0]}:${parts[1]}:00`
}
return normalized
}
function hasProductTradingWindow(start?: string | null, end?: string | null): boolean {
const open = formatTradingTimeDisplay(normalizeTradingTimeSetting(start, '00:00:00'))
const close = formatTradingTimeDisplay(normalizeTradingTimeSetting(end, '00:00:00'))
if (!open || !close) {
return false
}
return open !== close
}
export function setProductTradingHours(start?: string | null, end?: string | null) {
if (!hasProductTradingWindow(start, end)) {
productTradingHours.value = null
productTradingHoursVersion.value += 1
return
}
productTradingHours.value = {
start: normalizeTradingTimeSetting(start, '00:00'),
end: normalizeTradingTimeSetting(end, '23:59'),
}
productTradingHoursVersion.value += 1
}
export function syncProductTradingHoursFromProducts(
products: Array<{ auto_open_at?: string | null; auto_close_at?: string | null }>,
) {
for (const product of products) {
if (hasProductTradingWindow(product.auto_open_at, product.auto_close_at)) {
setProductTradingHours(product.auto_open_at, product.auto_close_at)
return
}
}
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'
return {
start: normalizeTradingTimeSetting(settings.getSettingByKey('startDay'), '00:00'),
end: normalizeTradingTimeSetting(settings.getSettingByKey('endDay'), '23:59'),
closePriceEnabled,
}
}
export function getEffectiveTradingHours(): EffectiveTradingHours {
void productTradingHoursVersion.value
const settings = getStoreTradingHours()
if (settings.closePriceEnabled) {
return {
start: settings.start,
end: settings.end,
enforced: true,
}
}
if (productTradingHours.value?.start && productTradingHours.value?.end) {
return {
start: productTradingHours.value.start,
end: productTradingHours.value.end,
enforced: true,
}
}
return {
start: settings.start,
end: settings.end,
enforced: false,
}
}
export function isStoreClosedBySchedule(now = new Date()): boolean {
const { start, end, enforced } = getEffectiveTradingHours()
if (!enforced) {
return false
}
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
const openSeconds = parseTimeToSeconds(start)
const closeSeconds = parseTimeToSeconds(end)
return currentSeconds < openSeconds || currentSeconds > closeSeconds
}
export function isStoreClosed(): boolean {
void productTradingHoursVersion.value
const { closePriceEnabled } = getStoreTradingHours()
if (closePriceEnabled) {
if (lastPrice.value?.closed === true) {
return true
}
return isStoreClosedBySchedule()
}
// وقتی close_price خاموش است، صفر بودن قیمت مرجع به معنی بسته بودن فروشگاه نیست.
return isStoreClosedBySchedule()
}
export function getStoreClosedMessage(): string {
const { start, end } = getEffectiveTradingHours()
const open = formatTradingTimeDisplay(start)
const close = formatTradingTimeDisplay(end)
if (!open || !close) {
return 'فروشگاه در حال حاضر بسته است.'
}
return `فروشگاه در حال حاضر بسته است. ساعت کاری از ${open} تا ${close} می‌باشد.`
}
+14 -6
View File
@@ -17,10 +17,16 @@ export interface SupportMessengerSettings {
export const supportMessengerSettings = ref<SupportMessengerSettings>({})
function getActiveSettingValue(settings: Array<{ key?: string; is_active?: boolean; value?: unknown }>, key: string) {
const setting = settings.find((item) => item?.key === key && item?.is_active === true)
const value = setting?.value
function getSupportSettingValue(
settings: Array<{ key?: string; is_active?: boolean | number; value?: unknown }>,
key: string
) {
const setting = settings.find((item) => item?.key === key)
if (!setting) return ''
const value = setting.value
if (value === undefined || value === null) return ''
return String(value).trim()
}
@@ -33,6 +39,8 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin
const type = (messenger ?? '').trim().toLowerCase()
const username = id.replace(/^@/, '')
if (!type) return ''
switch (type) {
case 'bale':
if (id.includes('ble.ir')) {
@@ -60,9 +68,9 @@ export async function loadSupportMessengerFromSettings() {
if (!settings || !Array.isArray(settings)) return
supportMessengerSettings.value = {
messenger: getActiveSettingValue(settings, 'support_messenger'),
messengerId: getActiveSettingValue(settings, 'support_messenger_id'),
messengerText: getActiveSettingValue(settings, 'support_messenger_text'),
messenger: getSupportSettingValue(settings, 'support_messenger'),
messengerId: getSupportSettingValue(settings, 'support_messenger_id'),
messengerText: getSupportSettingValue(settings, 'support_messenger_text'),
}
} catch (e) {
console.log(e)
+8 -3
View File
@@ -1,4 +1,5 @@
import {baseURL} from '@/themeConfig'
import { getConfigApp } from '@/utils/config'
import { baseURL as fallbackBaseURL } from '@/themeConfig'
import type {AxiosRequestConfig, AxiosResponse} from 'axios'
import axios from 'axios'
import {getToken, removeToken} from "@/utils";
@@ -20,12 +21,16 @@ const headers = {
}
const _axios = axios.create({
baseURL,
headers,
baseURL: fallbackBaseURL,
headers,
})
_axios.interceptors.request.use(
(config) => {
const runtimeBaseURL = getConfigApp('node_server')
if (runtimeBaseURL) {
config.baseURL = runtimeBaseURL
}
//console.log('request config', config)
if (config.headers) {
const token = getToken()
+55
View File
@@ -24,3 +24,58 @@ export function useTowDecimal(num: string | number): string | number {
export function useFormat(num: string | number, unit = '') {
return usePrice(Math.abs(+useTowDecimal(num)), true, unit)
}
type TransactionPriceSource = {
cash?: number | null
gold?: number | null
order?: {
price?: number | null
total_price?: number | null
amount?: number | null
} | null
}
export function getTransactionTradePrice(item: TransactionPriceSource): number {
const orderPrice = Number(item.order?.price)
if (Number.isFinite(orderPrice) && orderPrice > 0) {
return Math.round(orderPrice)
}
const cashAmount = Math.abs(Number(item.cash) || 0)
const goldAmount =
Math.abs(Number(item.gold) || 0) || Math.abs(Number(item.order?.amount) || 0)
if (goldAmount > 0 && cashAmount > 0) {
return Math.round(cashAmount / goldAmount)
}
const totalPrice = Number(item.order?.total_price)
if (Number.isFinite(totalPrice) && totalPrice > 0) {
return Math.round(totalPrice)
}
return 0
}
export function getTransactionGoldAmount(item: TransactionPriceSource): number {
const gold = Number(item.gold) || 0
if (Math.abs(gold) > 0) {
return gold
}
const orderAmount = Number(item.order?.amount) || 0
if (orderAmount > 0) {
const cash = Number(item.cash) || 0
if (cash < 0) return orderAmount
if (cash > 0) return -orderAmount
return orderAmount
}
return gold
}
export function formatGoldAmount(amount?: number | null): string {
const num = Number(amount)
if (!Number.isFinite(num)) return '0'
return String(Number(num.toFixed(3)))
}
+14 -3
View File
@@ -77,6 +77,7 @@ import BasicButton from "@/App.vue";
import BasicIcon from "@/components/BasicPacks/BasicIcon.vue";
import ProductItemVertical from "@/views/base/products/components/ProductItemVertical.vue";
import { loadProductCategoryTabNames } from '@/utils/categoryTabs'
import { subscribeProductListStream } from '@/utils/productStreamBridge'
const activekey = ref('همه')
const showFilter = ref()
@@ -122,7 +123,19 @@ const firstItems = ref<Product[]>()
const price = ref<Price>()
const items = ref<Product[]>()
const typeItems = ref([])
let unsubscribeProductStream: (() => void) | undefined
function applyStreamProducts(products: Product[]) {
const sorted = sortProductsByInventory(products)
firstItems.value = sorted
changeTab()
applyLivePricesToProducts(items.value)
applyLivePricesToProducts(firstItems.value)
}
onMounted(async () => {
unsubscribeProductStream = subscribeProductListStream(applyStreamProducts)
try {
loading.value = true
const products = sortProductsByInventory(await getProducts())
@@ -158,10 +171,8 @@ onMounted(async () => {
}
})
onUnmounted(() => {
unsubscribeProductStream?.()
stopPriceInterval()
// console.log('stop product')
// console.log('okgold')
})
function applyLivePricesToProducts(products: Product[] | undefined) {
@@ -12,8 +12,8 @@
<div>{{ toJalaliOutOfUTC(item.expire_at) }}</div>
</div>
<div class="flex justify-between mt-2">
<div :class="item.gold >= 0? 'text-green-600':'text-red-600'"
class="text-gray-500 text-xs number-ltr">{{ item.gold }}
<div :class="getTransactionGoldAmount(item) >= 0? 'text-green-600':'text-red-600'"
class="text-gray-500 text-xs number-ltr">{{ formatGoldAmount(getTransactionGoldAmount(item)) }}
<div>{{item.unit}}</div>
</div>
<div v-if="!isShopProduct" class="text-gray-500 flex gap-1 text-xs items-center number-ltr">
@@ -49,7 +49,7 @@
<div class="text-gray-500">قیمت معامله:</div>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<!-- <div class="text-base">{{ typeof parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0)) }}</div>-->
<div class="text-base">{{ usePriceWithoutType(parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0))) }}</div>
<div class="text-base">{{ usePriceWithoutType(getTransactionTradePrice(item)) }}</div>
ریال
</div>
</div>
@@ -112,8 +112,8 @@
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">مقدار {{item.wallet?.product? item.wallet.product.title: 'آبشده' }} مبادله شده:</div>
<div :class="item.gold >= 0? 'text-green-600' : 'text-red-600'" class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ item.gold }}</div>
<div :class="getTransactionGoldAmount(item) >= 0? 'text-green-600' : 'text-red-600'" class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ formatGoldAmount(getTransactionGoldAmount(item)) }}</div>
{{item.unit}}
</div>
</div>
@@ -184,7 +184,7 @@
<script lang="ts" setup>
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '../../../../utils/usePrice'
import { formatGoldAmount, getTransactionGoldAmount, getTransactionTradePrice, usePriceWithoutType } from '../../../../utils/usePrice'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { computed, ref } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
+2 -2
View File
@@ -40,7 +40,7 @@
<div v-if="isGold" class="flex w-full justify-between mt-2">
<div class="text-gray-500">قیمت معامله:</div>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<div class="text-base">{{ usePriceWithoutType((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0)) }}</div>
<div class="text-base">{{ usePriceWithoutType(getTransactionTradePrice(item)) }}</div>
ریال
</div>
</div>
@@ -138,7 +138,7 @@
<script lang="ts" setup>
import Icon from "@/components/BasicPacks/BasicIcon.vue";
import {usePriceWithoutType} from "../../../../utils/usePrice";
import {getTransactionTradePrice, usePriceWithoutType} from "../../../../utils/usePrice";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
import {computed, ref} from "vue";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
declare global {
interface Window {
__APP_CONFIG__?: Record<string, any>;
__BOOT_TIMEOUT__?: number;
__APP_BOOTSTRAPPED__?: boolean;
__APP_BOOT_SIMPLE_RETRY__?: () => void;
__APP_BOOT_CLEAN_RELOAD__?: () => Promise<void>;
}
}
export {};