From be1763110905ea65831ea2721b3b1cc58e87474a Mon Sep 17 00:00:00 2001 From: adonis Date: Mon, 29 Jun 2026 15:27:35 +0330 Subject: [PATCH] new update --- index.html | 704 +++++++++++++++++- public/.well-known/assetlinks.json | 10 - src/App.vue | 47 +- src/app/authBoot.ts | 53 ++ src/app/bootstrap.ts | 88 +++ src/app/createApp.ts | 18 + src/app/setupRouter.ts | 112 +++ src/components/BasicPacks/BasicButton.vue | 8 - src/config/app-config.ts | 37 + src/config/apply-config.ts | 83 +++ src/config/config-cache.ts | 277 +++++++ src/config/load-config.ts | 92 +++ src/config/load-version.ts | 167 +++++ src/config/types.ts | 20 + src/main.ts | 200 +---- src/modules/pwa/manifest-cache.ts | 72 ++ src/modules/pwa/manifest.ts | 558 ++++++++++++++ src/modules/pwa/register-pwa.ts | 33 + src/modules/version/VersionUpdateModal.vue | 223 ++++++ src/store/settingsStore.ts | 54 ++ src/utils/config.ts | 8 + src/utils/price.ts | 220 ++++-- src/utils/priceSocket.ts | 630 ++++++++++++++++ src/utils/priceSocketState.ts | 33 + src/utils/productStreamBridge.ts | 21 + src/utils/storeHours.ts | 164 ++++ src/utils/supportMessenger.ts | 20 +- src/utils/useAxios.ts | 11 +- src/utils/usePrice.ts | 55 ++ src/views/base/products/index.vue | 17 +- .../base/transactions/components/Item.vue | 12 +- src/views/base/wallet/components/Item.vue | 4 +- src/vite-env.d.ts | 13 + 33 files changed, 3713 insertions(+), 351 deletions(-) delete mode 100644 public/.well-known/assetlinks.json create mode 100644 src/app/authBoot.ts create mode 100644 src/app/bootstrap.ts create mode 100644 src/app/createApp.ts create mode 100644 src/app/setupRouter.ts create mode 100644 src/config/app-config.ts create mode 100644 src/config/apply-config.ts create mode 100644 src/config/config-cache.ts create mode 100644 src/config/load-config.ts create mode 100644 src/config/load-version.ts create mode 100644 src/config/types.ts create mode 100644 src/modules/pwa/manifest-cache.ts create mode 100644 src/modules/pwa/manifest.ts create mode 100644 src/modules/pwa/register-pwa.ts create mode 100644 src/modules/version/VersionUpdateModal.vue create mode 100644 src/store/settingsStore.ts create mode 100644 src/utils/config.ts create mode 100644 src/utils/priceSocket.ts create mode 100644 src/utils/priceSocketState.ts create mode 100644 src/utils/productStreamBridge.ts create mode 100644 src/utils/storeHours.ts create mode 100644 src/vite-env.d.ts diff --git a/index.html b/index.html index ec8ff93..82618a8 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,129 @@ - + - - - - - - - - Kashkol + + + + + + + + + در حال بارگذاری... + + + + -
+
+
+
+
+
+ +

در حال هدایت به سامانه

+

لطفاً چند لحظه صبر کنید...

+
+
+
+ + + - + \ No newline at end of file diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json deleted file mode 100644 index 8e3543d..0000000 --- a/public/.well-known/assetlinks.json +++ /dev/null @@ -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"] - } - } -] diff --git a/src/App.vue b/src/App.vue index e0568e2..80f3f2c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -55,42 +55,25 @@ - -
- -
نسخه جدید کشکول گلد منتشر شد!
-
-
+ diff --git a/src/config/app-config.ts b/src/config/app-config.ts new file mode 100644 index 0000000..c8f1785 --- /dev/null +++ b/src/config/app-config.ts @@ -0,0 +1,37 @@ +import type { AppConfig } from './types' + +type RawAppConfig = AppConfig & { + user?: Record +} + +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(key: string, fallback?: T): T { + const config = getTenantConfig() + const value = config?.[key] + + return (value ?? fallback ?? null) as T +} diff --git a/src/config/apply-config.ts b/src/config/apply-config.ts new file mode 100644 index 0000000..f0044f9 --- /dev/null +++ b/src/config/apply-config.ts @@ -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('avatar') || '/favicon.png' +} + +function setFavicon() { + let faviconLink = + document.querySelector("link[rel='icon']") || + document.querySelector("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( + `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("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(/^\/+/, '')}` +} diff --git a/src/config/config-cache.ts b/src/config/config-cache.ts new file mode 100644 index 0000000..e20a302 --- /dev/null +++ b/src/config/config-cache.ts @@ -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 +) { + 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, + version?: string | null +) { + const normalizedVersion = getNormalizedVersion(version) + + clearCachedAppConfig() + clearAppStorageExceptToken() + clearAllBootstrapFetchCaches() + await clearBrowserRuntimeCaches() + + const freshConfig = await fetchConfig() + + writeCachedAppConfig(freshConfig) + + if (normalizedVersion) { + writeStoredAppVersion(normalizedVersion) + } + + return freshConfig +} diff --git a/src/config/load-config.ts b/src/config/load-config.ts new file mode 100644 index 0000000..3eebce7 --- /dev/null +++ b/src/config/load-config.ts @@ -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 { + 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 { + 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 { + const cachedConfig = readCachedAppConfig() + + if (cachedConfig) { + return cachedConfig + } + + return await fetchFreshAppConfig() +} diff --git a/src/config/load-version.ts b/src/config/load-version.ts new file mode 100644 index 0000000..16d1a65 --- /dev/null +++ b/src/config/load-version.ts @@ -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 | 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 { + 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 { + 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 { + 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 +} diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..c6b9b8c --- /dev/null +++ b/src/config/types.ts @@ -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 + + [key: string]: any +} diff --git a/src/main.ts b/src/main.ts index 901c568..2c38f22 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 | 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()) - - diff --git a/src/modules/pwa/manifest-cache.ts b/src/modules/pwa/manifest-cache.ts new file mode 100644 index 0000000..53e743b --- /dev/null +++ b/src/modules/pwa/manifest-cache.ts @@ -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) +} diff --git a/src/modules/pwa/manifest.ts b/src/modules/pwa/manifest.ts new file mode 100644 index 0000000..721dfce --- /dev/null +++ b/src/modules/pwa/manifest.ts @@ -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('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 { + 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) { + const selectorParts = Object.entries(attrs) + .filter(([key]) => key !== 'href') + .map(([key, value]) => `[${key}="${value}"]`) + .join('') + + let link = document.querySelector(`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(`meta[name="${name}"]`) + + if (!meta) { + meta = document.createElement('meta') + meta.name = name + document.head.appendChild(meta) + } + + meta.content = content +} + +function removeAllManifestLinks() { + document.querySelectorAll("link[rel='manifest']").forEach((node) => node.remove()) +} + +function removeDynamicManifestLinks() { + removeAllManifestLinks() + revokeManifestBlobUrl() + + document + .querySelectorAll("link[rel='apple-touch-icon'][data-dynamic-pwa='true']") + .forEach((node) => node.remove()) +} + +function applyAppleTouchIconsFromManifest(manifest: PwaManifest) { + document + .querySelectorAll("link[rel='apple-touch-icon'][data-dynamic-pwa='true']") + .forEach((node) => node.remove()) + + const appleSizes = [180, 167, 152, 120] + const used = new Set() + + 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 +): Promise { + 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 { + 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()) + }) +} diff --git a/src/modules/pwa/register-pwa.ts b/src/modules/pwa/register-pwa.ts new file mode 100644 index 0000000..585a2c1 --- /dev/null +++ b/src/modules/pwa/register-pwa.ts @@ -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() + } + }) +} diff --git a/src/modules/version/VersionUpdateModal.vue b/src/modules/version/VersionUpdateModal.vue new file mode 100644 index 0000000..24f56ef --- /dev/null +++ b/src/modules/version/VersionUpdateModal.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts new file mode 100644 index 0000000..986fb46 --- /dev/null +++ b/src/store/settingsStore.ts @@ -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([]) + const loaded = ref(false) + let fetchPromise: Promise | 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, + } +}) diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..becbf76 --- /dev/null +++ b/src/utils/config.ts @@ -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 +} diff --git a/src/utils/price.ts b/src/utils/price.ts index db949df..e96a25e 100644 --- a/src/utils/price.ts +++ b/src/utils/price.ts @@ -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 | undefined +let lastSubscribers = 0 -let interval: any export const price = ref() export const lastPrice = ref() -export const olderPrice = ref(0) +export const olderPrice = ref(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 { + 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 { + 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 -} \ No newline at end of file + if (!olderPrice.value) { + return 0 + } + return ((lastPrice.value?.price_last! - olderPrice.value) * 100) / olderPrice.value +} diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts new file mode 100644 index 0000000..54a462f --- /dev/null +++ b/src/utils/priceSocket.ts @@ -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 | undefined +let staleCheckTimer: ReturnType | undefined +let reconnectAttempt = 0 +let lastStreamEventAt = 0 +let networkAlertOpen = false +let lastNetworkAlertAt = 0 +let listenersBound = false +let fallbackPollTimer: ReturnType | undefined +let fallbackPollInterval: ReturnType | 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 { + 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) +} diff --git a/src/utils/priceSocketState.ts b/src/utils/priceSocketState.ts new file mode 100644 index 0000000..1e19353 --- /dev/null +++ b/src/utils/priceSocketState.ts @@ -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('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 +} diff --git a/src/utils/productStreamBridge.ts b/src/utils/productStreamBridge.ts new file mode 100644 index 0000000..7b93c79 --- /dev/null +++ b/src/utils/productStreamBridge.ts @@ -0,0 +1,21 @@ +import type { Product } from '@/models/products' + +type ProductListHandler = (products: Product[]) => void + +const handlers = new Set() + +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)) +} diff --git a/src/utils/storeHours.ts b/src/utils/storeHours.ts new file mode 100644 index 0000000..5b74c8f --- /dev/null +++ b/src/utils/storeHours.ts @@ -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} می‌باشد.` +} diff --git a/src/utils/supportMessenger.ts b/src/utils/supportMessenger.ts index 35c4cd2..9516c3f 100644 --- a/src/utils/supportMessenger.ts +++ b/src/utils/supportMessenger.ts @@ -17,10 +17,16 @@ export interface SupportMessengerSettings { export const supportMessengerSettings = ref({}) -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) diff --git a/src/utils/useAxios.ts b/src/utils/useAxios.ts index 46ae03a..2e612a5 100644 --- a/src/utils/useAxios.ts +++ b/src/utils/useAxios.ts @@ -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() diff --git a/src/utils/usePrice.ts b/src/utils/usePrice.ts index f69748d..0bf0c34 100644 --- a/src/utils/usePrice.ts +++ b/src/utils/usePrice.ts @@ -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))) +} diff --git a/src/views/base/products/index.vue b/src/views/base/products/index.vue index 56e744d..a02fb18 100644 --- a/src/views/base/products/index.vue +++ b/src/views/base/products/index.vue @@ -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() const price = ref() const items = ref() 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) { diff --git a/src/views/base/transactions/components/Item.vue b/src/views/base/transactions/components/Item.vue index 027c284..e0a074b 100644 --- a/src/views/base/transactions/components/Item.vue +++ b/src/views/base/transactions/components/Item.vue @@ -12,8 +12,8 @@
{{ toJalaliOutOfUTC(item.expire_at) }}
-
{{ item.gold }} +
{{ formatGoldAmount(getTransactionGoldAmount(item)) }}
{{item.unit}}
@@ -49,7 +49,7 @@
قیمت معامله:
-
{{ usePriceWithoutType(parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0))) }}
+
{{ usePriceWithoutType(getTransactionTradePrice(item)) }}
ریال
@@ -112,8 +112,8 @@
مقدار {{item.wallet?.product? item.wallet.product.title: 'آبشده' }} مبادله شده:
-
-
{{ item.gold }}
+
+
{{ formatGoldAmount(getTransactionGoldAmount(item)) }}
{{item.unit}}
@@ -184,7 +184,7 @@