From 6e5ea0319f374cd1caa8e06c53a7adef3f54d1bd Mon Sep 17 00:00:00 2001 From: adonis Date: Tue, 16 Jun 2026 19:47:28 +0330 Subject: [PATCH] new UI and handle cashing --- components.d.ts | 1 + index.html | 755 +++++++++++++++++- src/app/authBoot.ts | 17 + src/app/bootstrap.ts | 114 +++ src/app/createApp.ts | 18 + src/app/setupRouter.ts | 157 ++++ src/config-loader.js | 232 ------ src/config/app-config.ts | 37 + src/config/apply-config.ts | 83 ++ src/config/config-cache.ts | 278 +++++++ src/config/load-config.ts | 92 +++ src/config/load-version.ts | 167 ++++ src/config/types.ts | 20 + src/layouts/mobile/MobileAppLayout.vue | 18 - src/layouts/mobile/MobileBottomNav.vue | 3 +- src/main.ts | 211 +---- src/modules/pwa/manifest-cache.ts | 72 ++ src/modules/pwa/manifest.ts | 552 +++++++++++++ src/modules/pwa/register-pwa.ts | 33 + src/modules/version/VersionUpdateModal.vue | 223 ++++++ src/store/configStore.ts | 6 +- src/store/userStore.ts | 5 +- src/themeConfig.ts | 65 +- src/utils/file.ts | 5 +- src/utils/index.ts | 8 +- src/utils/productAssets.ts | 2 +- src/utils/supportMessenger.ts | 2 +- src/utils/useAxios.ts | 10 +- .../base/home/components/AssetsSection.vue | 15 +- .../base/home/composables/useHomePage.ts | 30 +- .../base/home/composables/useHomePageData.ts | 15 +- .../components/DesktopAssetsMetrics.vue | 4 +- .../desktop/layouts/BoursecuDesktopHome.vue | 10 +- .../desktop/layouts/DefaultDesktopHome.vue | 10 +- .../base/home/layouts/HomeLayoutRenderer.vue | 12 +- src/views/base/home/layouts/registry.ts | 22 +- .../components/WalletDepositGuideModal.vue | 167 ++++ .../base/wallet/composables/useWalletPage.ts | 16 +- src/views/base/wallet/index.vue | 86 +- src/views/login/components/Login.vue | 2 +- src/views/login/composables/useLoginForm.ts | 2 +- 41 files changed, 2936 insertions(+), 641 deletions(-) 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 delete mode 100644 src/config-loader.js 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/views/base/wallet/components/WalletDepositGuideModal.vue diff --git a/components.d.ts b/components.d.ts index ecd8d5f..751269f 100644 --- a/components.d.ts +++ b/components.d.ts @@ -70,5 +70,6 @@ declare module 'vue' { TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default'] VanActionSheet: typeof import('vant/es')['ActionSheet'] VanDialog: typeof import('vant/es')['Dialog'] + VanField: typeof import('vant/es')['Field'] } } diff --git a/index.html b/index.html index 7b72735..d3ce1ab 100644 --- a/index.html +++ b/index.html @@ -1,29 +1,130 @@ - + - - - - - - - - - - - - + - - - -
- - - + })() + + + + + + +
+
+
+
+
+ +

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

+

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

+
+
+
+ + + + - + \ No newline at end of file diff --git a/src/app/authBoot.ts b/src/app/authBoot.ts new file mode 100644 index 0000000..bf78cd5 --- /dev/null +++ b/src/app/authBoot.ts @@ -0,0 +1,17 @@ +let fingerPosition = false + +export function setTruth() { + fingerPosition = true +} + +export function setEnableAuthenticate() { + fingerPosition = false +} + +export function getFingerPosition() { + return fingerPosition +} + +export function setFingerPosition(value: boolean) { + fingerPosition = value +} diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts new file mode 100644 index 0000000..f12ecee --- /dev/null +++ b/src/app/bootstrap.ts @@ -0,0 +1,114 @@ +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 { applyProjectTheme } from '@/utils/config' +import { useSettingsStore } from '@/store/settingsStore' +import { useUserStore } from '@/store/userStore' +import { getToken } from '@/utils' + +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(promise: Promise, ms: number, message: string): Promise { + return Promise.race([ + promise, + new Promise((_, 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 = ` +
+
+
⚠️
+

خطا در راه‌اندازی برنامه

+

+ لطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید. +

+ +
+
+ ` +} + +function applyTenantThemeVars() { + applyProjectTheme() + + const color = window.__APP_CONFIG__?.user?.color + const secondaryColor = window.__APP_CONFIG__?.user?.secondary_color + const backgroundColor = window.__APP_CONFIG__?.user?.background_color + + if (color) { + document.documentElement.style.setProperty('--App-primary-color', color) + } + + if (secondaryColor) { + document.documentElement.style.setProperty('--App-secondary-color', secondaryColor) + } + + if (backgroundColor) { + document.documentElement.style.setProperty('--App-background-color', backgroundColor) + } +} + +export async function bootstrapApp() { + try { + const config = await withTimeout(loadAppConfig(), 20000, 'Config loading timeout') + + setAppConfig(config) + applyTenantThemeVars() + startPwaManifest(config) + applyAppConfig() + + const { app } = createVueApp() + const settingsStore = useSettingsStore() + + await withTimeout(settingsStore.fetchSettings(), 45000, 'Settings loading timeout') + + setupRouterGuards() + + if (getToken()) { + await useUserStore().getMyInfo() + } + + 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) + } +} diff --git a/src/app/createApp.ts b/src/app/createApp.ts new file mode 100644 index 0000000..90d7ada --- /dev/null +++ b/src/app/createApp.ts @@ -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 } +} diff --git a/src/app/setupRouter.ts b/src/app/setupRouter.ts new file mode 100644 index 0000000..21ccde1 --- /dev/null +++ b/src/app/setupRouter.ts @@ -0,0 +1,157 @@ +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 { getConfigApp } from '@/utils/config' +import { getFingerPosition, setFingerPosition } from '@/app/authBoot' + +const PUBLIC_ROUTE_NAMES = ['login', '404', 'educate', 'aboutUs'] as const + +export function setupRouterGuards() { + const userStore = useUserStore() + const walletStore = useWalletStore() + const fundStore = useFundUserStore() + const authStore = useAuthStore() + + const isApple: boolean = isIphone() + let isFirst = true + let timeoutId: ReturnType | null = null + let hasFingerPrint = false + + 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 !== 'login') { + return next({ name: 'login' }) + } + return next() + } + + function goToHome(to: RouteLocationNormalized, next: NavigationGuardNext) { + if (to.name !== 'home') { + return next({ name: 'home' }) + } + return next() + } + + function isPublicRoute(name: string | symbol | null | undefined) { + return PUBLIC_ROUTE_NAMES.includes(name as (typeof PUBLIC_ROUTE_NAMES)[number]) + } + + router.beforeEach(async (to, from, next) => { + authStore.setLoading(true) + + try { + if (getToken() && !userStore.userInfo) { + await userStore.getMyInfo() + } + + if (timeoutId) { + clearTimeout(timeoutId) + } + + if (to.name == 'giftCard' && getConfigApp('name') != 'garoosi') { + return next({ name: from.name }) + } + + if (from.name !== 'login' || from.name !== undefined) { + timeoutId = setTimeout(() => { + if (hasFingerPrint) { + setFingerPosition(false) + console.log('Fingerprint authentication reset after 5 seconds of inactivity') + } + }, 180000) + } + + if (isFirst) { + isFirst = false + + if (getToken()) { + await userStore.getMyInfo() + + if (!userStore.userInfo) { + return goToLogin(to, next) + } + + const finger_print = userStore.userInfo.finger_print + hasFingerPrint = !!finger_print + + if (isApple && finger_print) { + removeToken() + return goToLogin(to, next) + } + + if (!isApple && finger_print) { + setFingerPosition(await authenticateWithFingerprint()) + + if (!getFingerPosition()) { + return goToLogin(to, next) + } + + if (to.name === 'login') { + return goToHome(to, next) + } + + return next() + } + + if (to.name == 'login') { + return goToHome(to, next) + } + + return next() + } + + if (isPublicRoute(to.name)) { + return next() + } + + return goToLogin(to, next) + } + + if (getFingerPosition()) { + if (getToken()) { + if (to.name === 'login') { + return next({ name: from.name || 'home' }) + } + return next() + } + + if (isPublicRoute(to.name)) { + return next() + } + + return next({ name: 'login' }) + } + + if (getToken() && !hasFingerPrint) { + return next() + } + + if (isPublicRoute(to.name)) { + if (to.name === 'login') { + return goToLogin(to, next) + } + return next() + } + + return goToLogin(to, next) + } finally { + authStore.setLoading(false) + } + }) + + watch(currentRoute, async () => (userStore.userInfo ? '' : await start())) +} diff --git a/src/config-loader.js b/src/config-loader.js deleted file mode 100644 index c07d713..0000000 --- a/src/config-loader.js +++ /dev/null @@ -1,232 +0,0 @@ -;(function () { - 'use strict' - //debugger - - // Store config in window object - window.__APP_CONFIG__ = window.__APP_CONFIG__ || {} - window.__CONFIG_READY__ = false - - // Create a promise that will be resolved when config is loaded - let configResolve - let configReject - window.__CONFIG_PROMISE__ = new Promise((resolve, reject) => { - configResolve = resolve - configReject = reject - }) - const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; - - const CONFIG_URLS = [ - `${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`, - ] - const CONFIG_Success_URL = localStorage.getItem('CONFIG_Success_URL') - const sortedConfigUrls = [...CONFIG_URLS].sort((a, b) => { - if (a === CONFIG_Success_URL) return -1 - if (b === CONFIG_Success_URL) return 1 - return 0 - }) - - // Timeout to ensure main.ts loads even if config fails (5 seconds) - const CONFIG_TIMEOUT = 5000 - let configTimeoutId - let mainScriptLoaded = false - - async function getConfigsFromUrl(url, timeout = 8000) { - const controller = new AbortController() - const id = setTimeout(() => controller.abort(), timeout) - - try { - const response = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - }, - cache: 'no-cache', - signal: controller.signal, - }) - - return response - } catch (error) { - if (error.name === 'AbortError') { - throw new Error(`Request timeout after ${timeout}ms`) - } - throw error - } finally { - clearTimeout(id) - } - } - - let retryGetConfig = 3; - // Fetch config from server - async function loadConfig() { - try { - let response = undefined - for (let i = 0; i < sortedConfigUrls.length; i++) { - try { - response = await getConfigsFromUrl(sortedConfigUrls[i],i', e) - continue; - } - } - if (!response?.ok) { - if (retryGetConfig>0){ - retryGetConfig--; - return loadConfig() - } - throw new Error('Failed to fetch config: ' + response.status) - } - // Check if response is actually JSON - const contentType = response.headers.get('content-type') - if (!contentType || !contentType.includes('application/json')) { - const text = await response.text() - throw new Error('Response is not JSON. Received: ' + text.substring(0, 100)) - } - - const config = await response.json() - - // Store config in window object - window.__APP_CONFIG__ = config - window.__CONFIG_READY__ = true - - // Early splash background — full viewport before Vue mounts - const projectName = config?.user?.name - if (projectName === 'boursecu') { - const splashBg = '#133050' - document.documentElement.style.backgroundColor = splashBg - document.body.style.backgroundColor = splashBg - const metaTheme = document.querySelector('meta[name="theme-color"]') - if (metaTheme) metaTheme.setAttribute('content', splashBg) - } - - if (config?.user?.name_fa) { - document.title = config.user.name_fa - } - - if (config?.user?.avatar) { - const faviconUrl = config.user.avatar - const existingIcons = document.querySelectorAll('link[rel*="icon"]') - existingIcons.forEach((icon) => icon.remove()) - - const link = document.createElement('link') - link.rel = 'icon' - link.type = 'image/png' - link.href = faviconUrl + '?v=' + Date.now() - document.head.appendChild(link) - } - - // Resolve the promise - if (configResolve) { - configResolve(config) - } - - // Dispatch custom event to notify that config is ready - window.dispatchEvent(new CustomEvent('configReady', { detail: config })) - - console.log('Config loaded successfully:', config) - - // Clear timeout if config loaded successfully - if (configTimeoutId) { - clearTimeout(configTimeoutId) - configTimeoutId = null - } - - // Load main.ts after config is ready - loadMainScript() - } catch (error) { - console.warn('Config loader: Could not fetch config from', CONFIG_URLS) - console.warn('Error details:', error.message) - console.warn( - 'This is normal if the endpoint does not exist yet. App will continue with empty config.' - ) - // Set default empty config on error - window.__APP_CONFIG__ = {} - window.__CONFIG_READY__ = true - - // Resolve the promise even on error (with empty config) - if (configResolve) { - configResolve({}) - } - - window.dispatchEvent(new CustomEvent('configError', { detail: error })) - - // Clear timeout if error occurred - if (configTimeoutId) { - clearTimeout(configTimeoutId) - configTimeoutId = null - } - - // Load main.ts even on error (with empty config) - loadMainScript() - } - } - - // Function to dynamically load main.ts script - function loadMainScript() { - //const config= window.__APP_CONFIG__ - // Check if main.ts is already loaded - if (mainScriptLoaded || document.querySelector('script[src="/src/main.ts"]')) { - return - } - - mainScriptLoaded = true - - /*const manifestLink = document.createElement("link") - manifestLink.rel = "manifest" - manifestLink.id= "manifestsdfsdfsdfsdfsdfsdf" - manifestLink.href = `https://nerkh.gold/get_user/${config.user.id}/monifist.json` - - document.head.appendChild(manifestLink)*/ - - import('/src/main.ts').then(() => { - console.log('Main script loaded and executed') - }) - - /*const script = document.createElement('script'); - script.type = 'module'; - script.src = '/src/main.ts'; - script.async = true;*/ - - // Handle script load errors - /*script.onerror = function() { - console.error('Failed to load main.ts script'); - mainScriptLoaded = false; - }; - - script.onload = function() { - console.log('Main script loaded successfully'); - }; - - document.body.appendChild(script);*/ - - //console.log('Main script loading started...'); - } - - // Set timeout to ensure main.ts loads even if config fails - configTimeoutId = setTimeout(function () { - if (!mainScriptLoaded) { - console.warn('Config loader: Timeout reached. Loading main.ts with empty config.') - // Set default empty config - window.__APP_CONFIG__ = window.__APP_CONFIG__ || {} - window.__CONFIG_READY__ = true - - // Resolve promise with empty config - if (configResolve) { - configResolve({}) - } - - loadMainScript() - } - }, CONFIG_TIMEOUT) - - // Start loading config immediately - loadConfig() -})() 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..fa61bfb --- /dev/null +++ b/src/config/config-cache.ts @@ -0,0 +1,278 @@ +import type { AppConfig } from '@/config/types' +import { resetVersionCheckCache } from '@/config/load-version' +import { clearCachedPwaManifest } from '@/modules/pwa/manifest-cache' +import { getTokenName } 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 tokenKey = getTokenName() + const token = localStorage.getItem(tokenKey) + + localStorage.clear() + + if (token) { + localStorage.setItem(tokenKey, 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..3e29f2f --- /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:' +} + +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` + ] +} + +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..04dbc4e --- /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 an HTTP 200 but empty body => 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/layouts/mobile/MobileAppLayout.vue b/src/layouts/mobile/MobileAppLayout.vue index 97fb014..4d98b19 100644 --- a/src/layouts/mobile/MobileAppLayout.vue +++ b/src/layouts/mobile/MobileAppLayout.vue @@ -1,12 +1,5 @@ @@ -30,28 +22,18 @@ import { computed } from 'vue' import { useConfigStore } from '@/store/configStore' import { getProjectMobileNavLogo, isBoursecuProject } from '@/utils/config' -import { SECONDARY_NAV } from '../config/navigation' import { useAppNavigation } from '../composables/useAppNavigation' import MobileBottomNav from './MobileBottomNav.vue' -import MobileOverflowMenu from './MobileOverflowMenu.vue' const config = useConfigStore() const isBoursecu = isBoursecuProject() const navLogo = computed(() => getProjectMobileNavLogo(config.config?.avatar)) const { - popVisible, hideMenu, navItemClass, navigateTo, - toggleOverflowMenu, - closeOverflowMenu, } = useAppNavigation() - -const overflowItems = [ - { id: 'user', routeName: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user' }, - ...SECONDARY_NAV, -] diff --git a/src/layouts/mobile/MobileBottomNav.vue b/src/layouts/mobile/MobileBottomNav.vue index e0dc5aa..185ae47 100644 --- a/src/layouts/mobile/MobileBottomNav.vue +++ b/src/layouts/mobile/MobileBottomNav.vue @@ -61,7 +61,7 @@ class="circle_in" :class="isWhiteNavLogo ? 'is-white-nav-logo' : 'text-white'" > -
+
() const projectName = getConfigApp('name') diff --git a/src/main.ts b/src/main.ts index 8b1f981..1bf1365 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,209 +1,16 @@ import 'ant-design-vue/dist/antd.less' import './assets/css/main.less' import 'virtual:uno.css' -import {computed, createApp, nextTick, ref, watch} from 'vue' -import { createPinia } from 'pinia' import '@unocss/reset/tailwind-compat.css' -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 VueApexCharts from 'vue3-apexcharts' -import { getMe } from '@/api/me' -import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router' -import { useAuthStore } from '@/store/useAuthStore' -import {applyProjectTheme, getConfigApp} from "@/utils/config"; -import { useSettingsStore } from '@/store/settingsStore' +import { initPwaInstallCapture } from '@/modules/pwa/manifest' +import { bootstrapApp } from '@/app/bootstrap' -const app = createApp(App) -app.use(createPinia()) -app.use(VueLazyLoad, { - // options... -}) -app.use(router) -app.use(VueApexCharts) - -const userStore = useUserStore() -const walletStore = useWalletStore() -const fundStore = useFundUserStore() -const authStore = useAuthStore() -const settingsStore = useSettingsStore() -void settingsStore.fetchSettings() -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() { - if (getToken()) { - // await userStore.getMyInfo() - await walletStore.getLimitedPrice() - await walletStore.getLimitedBuyGold() - await fundStore.getMyFund() - } -} - -const win = window as Window & { isWhatsappBtnAdded?: boolean } - -export function setTruth() { - fingerPosition = true -} - -export function setEnableAuthenticate() { - fingerPosition = false -} - - -function goToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) { - 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 !== 'home') { - return next({ name: 'home' }) // 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) - } - if (to.name=='giftCard' && getConfigApp('name')!='garoosi') - return next({ name: from.name }) - // Start a new timeout to reset fingerprint after 5 seconds of inactivity - if (from.name !== 'login' || from.name !== undefined) { - 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) { - fingerPosition = await authenticateWithFingerprint() - - if (fingerPosition) { - return next() - } - - goToLogin(to, next) - } else { - if (to.name === 'login') { - return goToHome(to, next) - } - return next() - } - } else { - // If no token, handle redirect to login or allow access to login or 404 - if (['login', '404','educate', 'aboutUs'].includes(to.name as string)) { - return next() // Allow access to login or 404 - } - 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 || 'home' }) // 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','educate', 'aboutUs'].includes(to.name as string)) { - return next() // Allow access to login or 404 - } - return next({ name: 'login' }) - } - } - // If fingerprint authentication failed or was not done - else { - if (getToken() && !hasFingerPrint) { - authStore.setLoading(false) - return next() - } - - if (['login', '404', 'educate', 'aboutUs'].includes(to.name as string)) { - authStore.setLoading(false) - if (to.name === 'login' && !getToken()) { - return goToLogin(to, next) - } - return next() - } - - authStore.setLoading(false) - return next({ name: 'login' }) - } - } - - authStore.setLoading(false) -}) -app.mount('#app') -// if (currentRoute.value !== '/') { -// await start() -// } - -nextTick(() => { - applyProjectTheme() - - const color = window.__APP_CONFIG__?.user?.color - const secondary_color = window.__APP_CONFIG__?.user?.secondary_color - - - if (!color) { - console.warn('Primary color not found') - return - } - console.log('APP_CONFIG:', window.__APP_CONFIG__.user.color) - document.documentElement.style.setProperty('--App-primary-color', color) - document.documentElement.style.setProperty('--App-secondary-color', secondary_color) - -}) -watch(currentRoute, async () => userStore.userInfo ? '' : await start()) - +export { + setTruth, + setEnableAuthenticate, + setFingerPosition, +} from '@/app/authBoot' +initPwaInstallCapture() +void bootstrapApp() 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..369f30f --- /dev/null +++ b/src/modules/pwa/manifest.ts @@ -0,0 +1,552 @@ +import type { AppConfig } from '@/config/types' +import { getConfig, getRawAppConfig } from '@/config/app-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_COUNT = 5 +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) +} + +/** یک URL — فقط monifist همان tenant/user؛ بدون fallback عمومی */ +export function resolveManifestUrl(config?: AppConfig | null) { + const raw = config ?? getRawAppConfig() + const userId = raw?.user?.id + const base = getConfigBaseFromStorage() + + if (!base) return '' + + const explicit = getConfig('manifest_url') + if (explicit) return explicit + +/* if (userId != null && userId !== '') { + return `${base}/get_user/${userId}/manifest.json` + }*/ + + return `${base}/get_user/manifest.json` +} + +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) + } +} + +/** retry فقط روی همان URL — بدون درخواست دوم */ +async function fetchManifestWithRetry(url: string): Promise { + if (!url) { + throw new Error('No manifest URL (config base or user id missing)') + } + + let lastError: unknown = null + + for (let attempt = 1; attempt <= MANIFEST_RETRY_COUNT; attempt++) { + try { + return await fetchManifestOnce(url) + } catch (error) { + lastError = error + console.warn(`PWA manifest failed (${url}), attempt ${attempt}/${MANIFEST_RETRY_COUNT}`, error) + } + + if (attempt < MANIFEST_RETRY_COUNT) { + await sleep(MANIFEST_RETRY_DELAY_MS * attempt) + } + } + + throw lastError ?? new Error('PWA manifest could not be loaded') +} + +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 نمی‌شود — فقط monifist.json از base کش‌شده. + */ +export async function setupPwaManifest(config?: AppConfig | null): Promise { + const raw = config ?? getRawAppConfig() + const userId = raw?.user?.id + const configBase = getConfigBaseFromStorage() + + if (!configBase) { + console.warn('PWA manifest skipped: CONFIG_Success_URL not set yet') + removeDynamicManifestLinks() + setPwaManifestReady(false) + return false + } + + const cached = readCachedPwaManifest(userId, configBase) + + if (cached && (await applyCachedManifestEntry(cached))) { + console.info('PWA manifest applied from cache:', cached.manifest_url) + return true + } + + const manifestUrl = resolveManifestUrl(raw) + + try { + const manifest = await fetchManifestWithRetry(manifestUrl) + + const prepared = prepareManifestForInstall(manifest) + + await applyPreparedManifestToDocument(prepared, manifestUrl) + setPwaManifestReady(true) + + if (userId != null && userId !== '') { + writeCachedPwaManifest({ + cached_at: Date.now(), + user_id: userId, + config_base: configBase, + 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/configStore.ts b/src/store/configStore.ts index d76c2be..7a7b650 100644 --- a/src/store/configStore.ts +++ b/src/store/configStore.ts @@ -10,12 +10,10 @@ import {getConfig} from "@/api/config"; export const useConfigStore = defineStore('config', () => { - const config = ref() + const config = ref(window.__APP_CONFIG__?.user) async function getConfigData() { - - let temp = window.__APP_CONFIG__ - config.value = temp.user + config.value = window.__APP_CONFIG__?.user console.log(config.value) } diff --git a/src/store/userStore.ts b/src/store/userStore.ts index dbcdca2..d4a566a 100644 --- a/src/store/userStore.ts +++ b/src/store/userStore.ts @@ -19,9 +19,10 @@ export const useUserStore = defineStore('user', () => { try { userInfo.value = await getMe() useSettingsStore().mergeSettings(userInfo.value.settings) - setFingerPrint(userInfo.value.finger_print.toString()) + if (userInfo.value?.finger_print != null) { + setFingerPrint(String(userInfo.value.finger_print)) + } productWallet.value = userInfo.value.pwallet - // return userInfo.value walletStore.walletInfo = userInfo.value.wallet } catch (e) { console.log(e) diff --git a/src/themeConfig.ts b/src/themeConfig.ts index 9c6ae2e..65721cf 100644 --- a/src/themeConfig.ts +++ b/src/themeConfig.ts @@ -1,31 +1,46 @@ -import pkg from '../package.json' import colors from './baseColor.json' -import { getConfigApp} from "@/utils/config"; - -/** base config **/ -/** api document **/ -// export const baseURL: string = 'https://api.gholaktala.ir/' -const projectBase = getConfigApp('node_server') -const projectName = getConfigApp('name') - -export const baseURL: string = projectBase -// export const baseURL: string = 'https://api-gholaktala.z23.ir/' -/** Customer profile image upload (backend: POST /customer/avatar) */ -export const avatarUploadURL: string = `${projectBase.replace(/\/$/, '')}/customer/avatar` -// const baseURLStorage: string = baseURL + '/v1' -const prefixCls: string = 'noname' -// const version: string = pkg.version -// const tokenName: string = 'gholaktala_app_token' -const tokenName: string = `${projectName}_app_token` +import { getConfigApp } from '@/utils/config' +const prefixCls = 'noname' const baseButtonIconSize = '24' -// const appName = { fa: 'بدون نام', en: 'noname' } -/** Defining the secret key for crypto-js **/ -// const secretKey: string = 'pwd@1234' + +function readCachedTenantField(key: string): string { + try { + const cookie = document.cookie.split('; ').find((row) => row.startsWith('APP_CONFIG_CACHE=')) + if (!cookie) return '' + + const payload = JSON.parse(decodeURIComponent(cookie.slice('APP_CONFIG_CACHE='.length))) + const user = payload?.config?.user + const value = user?.[key] + + return value != null ? String(value) : '' + } catch { + return '' + } +} + +export function resolveProjectBase(): string { + return getConfigApp('node_server') || readCachedTenantField('node_server') || '' +} + +/** fallback خالی — useAxios در هر درخواست node_server را از config می‌گیرد */ +export const baseURL = '' + +export function getAvatarUploadURL(): string { + const base = resolveProjectBase().replace(/\/$/, '') + return base ? `${base}/customer/avatar` : '/customer/avatar' +} + +export function getTokenName(): string { + const name = getConfigApp('name') || readCachedTenantField('name') || 'app' + return `${name}_app_token` +} + +/** @deprecated از getTokenName() استفاده کن */ +export const tokenName = 'app_app_token' export { - prefixCls, - colors, - baseButtonIconSize, - tokenName + prefixCls, + colors, + baseButtonIconSize, } diff --git a/src/utils/file.ts b/src/utils/file.ts index 28dcbfc..7f88c27 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,4 +1,4 @@ -import { baseURL } from '@/themeConfig' +import { resolveProjectBase } from '@/themeConfig' import type { User } from '@/models/login' export interface ProductFile { @@ -19,7 +19,8 @@ export function getFileUrl(file?: ProductFile | null): string | null { const relative = file.src || file.full_src if (!relative) return null - const base = baseURL.replace(/\/$/, '') + const base = resolveProjectBase().replace(/\/$/, '') + if (!base) return relative.startsWith('/') ? relative : `/${relative}` const path = String(relative).replace(/^\//, '') return `${base}/${path}` } diff --git a/src/utils/index.ts b/src/utils/index.ts index 371a43a..78ddc34 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import { tokenName } from '@/themeConfig' +import { getTokenName } from '@/themeConfig' import { ref } from 'vue' import {useConfigStore} from "@/store/configStore"; @@ -11,7 +11,7 @@ const nameProject= getConfigApp('name') function setToken(token: string | undefined) { - return localStorage.setItem(tokenName, token) + return localStorage.setItem(getTokenName(), token) } function setFingerPrint(val: string | undefined) { @@ -19,7 +19,7 @@ function setFingerPrint(val: string | undefined) { } function getToken() { - return localStorage.getItem(tokenName) + return localStorage.getItem(getTokenName()) } function getIsActiveFingerprint() { @@ -78,7 +78,7 @@ export async function authenticateWithFingerprint() { function removeToken() { - return localStorage.removeItem(tokenName) + return localStorage.removeItem(getTokenName()) } function removeFingerPrint() { diff --git a/src/utils/productAssets.ts b/src/utils/productAssets.ts index 7d95cb1..a053858 100644 --- a/src/utils/productAssets.ts +++ b/src/utils/productAssets.ts @@ -340,7 +340,7 @@ export function buildBoursecuHomeAssetRows( rows.push({ id: 'rial-total', label: 'دارایی ریالی', - value: `${formatRialNumber(summary.total_rial_value ?? 0)} ریال`, + value: `${formatRialNumber(summary.cash_wallet ?? 0)} ریال`, info: 'rial', }) diff --git a/src/utils/supportMessenger.ts b/src/utils/supportMessenger.ts index 70823b1..75d1ad1 100644 --- a/src/utils/supportMessenger.ts +++ b/src/utils/supportMessenger.ts @@ -19,7 +19,7 @@ export interface SupportMessengerSettings { export const supportMessengerSettings = ref({}) export const DEFAULT_SUPPORT_MESSENGER_TEXT = - 'جهت شارژ حساب کاربری خود، مبلغ موردنظر را واریز نموده و از طریق پیام‌رسان به پشتیبانی اطلاع دهید.' + 'جهت شارژ حساب کاربری خود، کلمه «شماره حساب» را برای پشتیبانی ارسال نمایید.' function getSupportSettingValue( settings: Array<{ key?: string; is_active?: boolean | number; value?: unknown }>, diff --git a/src/utils/useAxios.ts b/src/utils/useAxios.ts index 84887e9..e3bb0bc 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,13 +21,16 @@ const headers = { } const _axios = axios.create({ - baseURL, + baseURL: fallbackBaseURL, headers, }) _axios.interceptors.request.use( (config) => { - //console.log('request config', config) + const runtimeBaseURL = getConfigApp('node_server') + if (runtimeBaseURL) { + config.baseURL = runtimeBaseURL + } if (config.headers) { const token = getToken() if (token) { diff --git a/src/views/base/home/components/AssetsSection.vue b/src/views/base/home/components/AssetsSection.vue index 81498b8..f1a7301 100644 --- a/src/views/base/home/components/AssetsSection.vue +++ b/src/views/base/home/components/AssetsSection.vue @@ -39,6 +39,17 @@ @click="$emit('openGoldModal', primaryMetalTotal)" />
+ +
+ دارایی ریالی: +
{{ usePriceWithoutType(cashWallet) }} ریال
+ +