new UI and handle cashing
This commit is contained in:
Vendored
+1
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
+711
-44
@@ -1,29 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- <link rel="icon" href="/favicon.png">-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="enamad" content="44889195">
|
||||
<title></title>
|
||||
<script>
|
||||
(function () {
|
||||
if (window.matchMedia('(min-width: 768px)').matches) {
|
||||
document.documentElement.classList.add('app-layout--desktop')
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<style>
|
||||
/* Critical: full-viewport splash before Vue/CSS hydrate */
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="enamad" content="44889195" />
|
||||
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
|
||||
<title>در حال بارگذاری...</title>
|
||||
<!---->
|
||||
<style>
|
||||
:root {
|
||||
--boot-bg: #ffffff;
|
||||
--boot-bg-soft: #f8fafc;
|
||||
--boot-card: rgba(255, 255, 255, 0.88);
|
||||
--boot-border: rgba(15, 23, 42, 0.08);
|
||||
--boot-text: #111827;
|
||||
--boot-muted: rgba(15, 23, 42, 0.62);
|
||||
--boot-accent: #2563eb;
|
||||
--boot-orb: rgba(37, 99, 235, 0.10);
|
||||
--boot-spinner-track: rgba(15, 23, 42, 0.10);
|
||||
--boot-shadow: rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--boot-bg);
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.boot-loader {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
direction: rtl;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top, var(--boot-orb), transparent 38%),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--boot-bg) 0%,
|
||||
var(--boot-bg-soft) 100%
|
||||
);
|
||||
color: var(--boot-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.boot-loader__card {
|
||||
position: relative;
|
||||
width: min(340px, calc(100vw - 48px));
|
||||
padding: 30px 22px 28px;
|
||||
border-radius: 24px;
|
||||
text-align: center;
|
||||
background: var(--boot-card);
|
||||
border: 1px solid var(--boot-border);
|
||||
box-shadow: 0 18px 48px var(--boot-shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.boot-loader__orb {
|
||||
position: absolute;
|
||||
top: -72px;
|
||||
left: 50%;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
background: var(--boot-orb);
|
||||
filter: blur(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.boot-loader__spinner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 999px;
|
||||
border: 4px solid var(--boot-spinner-track);
|
||||
border-top-color: var(--boot-accent);
|
||||
animation: boot-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.boot-loader__title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.boot-loader__text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 10px 0 0;
|
||||
color: var(--boot-muted);
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
@keyframes boot-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Critical: full-viewport splash before Vue/CSS hydrate */
|
||||
@media (min-width: 768px) {
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
@@ -42,26 +143,592 @@
|
||||
right: 0 !important;
|
||||
z-index: 2147483646;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app"></div>
|
||||
<!--<script type="module" src="/src/main.ts"></script>-->
|
||||
<script type="module" src="/src/config-loader.js"></script>
|
||||
<!--<script type="text/javascript">
|
||||
['keydown', 'touchmove', 'touchstart', 'mouseover'].forEach(function(v) {
|
||||
window.addEventListener(v, function() {
|
||||
if (!window.isGoftinoAdded) {
|
||||
window.isGoftinoAdded = 1
|
||||
var i = 'btsTHe', d = document, g = d.createElement('script'), s = 'https://www.goftino.com/widget/' + i,
|
||||
l = localStorage.getItem('goftino_' + i)
|
||||
g.type = 'text/javascript', g.async = !0, g.src = l ? s + '?o=' + l : s
|
||||
d.getElementsByTagName('head')[0].appendChild(g)
|
||||
<script>
|
||||
(function () {
|
||||
if (window.matchMedia('(min-width: 768px)').matches) {
|
||||
document.documentElement.classList.add('app-layout--desktop')
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>-->
|
||||
})()
|
||||
</script>
|
||||
|
||||
<script>
|
||||
;(function () {
|
||||
var CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
|
||||
|
||||
function getCookie(name) {
|
||||
var cookie = document.cookie.split('; ').find(function (row) {
|
||||
return row.indexOf(name + '=') === 0
|
||||
})
|
||||
|
||||
if (!cookie) return null
|
||||
|
||||
return cookie.substring(name.length + 1)
|
||||
}
|
||||
|
||||
function readCachedConfig() {
|
||||
try {
|
||||
var rawCookie = getCookie(CONFIG_COOKIE_NAME)
|
||||
|
||||
if (!rawCookie) return null
|
||||
|
||||
var decoded = decodeURIComponent(rawCookie)
|
||||
var payload = JSON.parse(decoded)
|
||||
|
||||
return payload && payload.config ? payload.config : null
|
||||
} catch (error) {
|
||||
console.warn('Boot theme: failed to read cached config', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHex(hex) {
|
||||
if (!hex || typeof hex !== 'string') return null
|
||||
|
||||
var value = hex.trim()
|
||||
|
||||
if (!value) return null
|
||||
|
||||
if (value[0] !== '#') {
|
||||
value = '#' + value
|
||||
}
|
||||
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(value)) {
|
||||
value =
|
||||
'#' +
|
||||
value[1] +
|
||||
value[1] +
|
||||
value[2] +
|
||||
value[2] +
|
||||
value[3] +
|
||||
value[3]
|
||||
}
|
||||
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(value)) return null
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
var normalized = normalizeHex(hex)
|
||||
|
||||
if (!normalized) return null
|
||||
|
||||
var num = parseInt(normalized.slice(1), 16)
|
||||
|
||||
return {
|
||||
r: (num >> 16) & 255,
|
||||
g: (num >> 8) & 255,
|
||||
b: num & 255
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
function toHex(value) {
|
||||
var hex = Math.max(0, Math.min(255, Math.round(value))).toString(16)
|
||||
return hex.length === 1 ? '0' + hex : hex
|
||||
}
|
||||
|
||||
return '#' + toHex(r) + toHex(g) + toHex(b)
|
||||
}
|
||||
|
||||
function mix(hexA, hexB, weight) {
|
||||
var a = hexToRgb(hexA)
|
||||
var b = hexToRgb(hexB)
|
||||
|
||||
if (!a || !b) return hexA
|
||||
|
||||
return rgbToHex(
|
||||
a.r * (1 - weight) + b.r * weight,
|
||||
a.g * (1 - weight) + b.g * weight,
|
||||
a.b * (1 - weight) + b.b * weight
|
||||
)
|
||||
}
|
||||
|
||||
function brightness(hex) {
|
||||
var rgb = hexToRgb(hex)
|
||||
|
||||
if (!rgb) return 0
|
||||
|
||||
return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
var rgb = hexToRgb(hex)
|
||||
|
||||
if (!rgb) return 'rgba(96, 165, 250, ' + alpha + ')'
|
||||
|
||||
return 'rgba(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ', ' + alpha + ')'
|
||||
}
|
||||
|
||||
function setVar(name, value) {
|
||||
document.documentElement.style.setProperty(name, value)
|
||||
}
|
||||
|
||||
function applyBootTheme() {
|
||||
var config = readCachedConfig()
|
||||
var user = config && config.user ? config.user : null
|
||||
|
||||
if (!user) return
|
||||
|
||||
var tenantColor = normalizeHex(user.color)
|
||||
var tenantSecondary = normalizeHex(user.secondary_color)
|
||||
var tenantBackground = normalizeHex(user.background_color)
|
||||
|
||||
/**
|
||||
* مهم:
|
||||
* background_color بکگراند اپ است.
|
||||
* اگر نبود، لودینگ باید روشن بماند، نه اینکه color را بکگراند کنیم.
|
||||
*/
|
||||
var bg = tenantBackground || '#ffffff'
|
||||
|
||||
/**
|
||||
* color و secondary_color فقط برای accent استفاده میشوند.
|
||||
*/
|
||||
var accent = tenantSecondary || tenantColor || '#2563eb'
|
||||
|
||||
var isLight = brightness(bg) > 155
|
||||
|
||||
var bgSoft = isLight ? mix(bg, accent, 0.045) : mix(bg, '#ffffff', 0.055)
|
||||
|
||||
var text = isLight ? '#111827' : '#ffffff'
|
||||
var muted = isLight ? 'rgba(15, 23, 42, 0.62)' : 'rgba(255, 255, 255, 0.70)'
|
||||
|
||||
var card = isLight ? 'rgba(255, 255, 255, 0.88)' : 'rgba(255, 255, 255, 0.07)'
|
||||
var border = isLight ? 'rgba(15, 23, 42, 0.08)' : 'rgba(255, 255, 255, 0.12)'
|
||||
|
||||
var orb = hexToRgba(accent, isLight ? 0.10 : 0.18)
|
||||
var spinnerTrack = isLight ? 'rgba(15, 23, 42, 0.10)' : 'rgba(255, 255, 255, 0.14)'
|
||||
var shadow = isLight ? 'rgba(15, 23, 42, 0.10)' : 'rgba(0, 0, 0, 0.24)'
|
||||
|
||||
setVar('--boot-bg', bg)
|
||||
setVar('--boot-bg-soft', bgSoft)
|
||||
setVar('--boot-accent', accent)
|
||||
setVar('--boot-text', text)
|
||||
setVar('--boot-muted', muted)
|
||||
setVar('--boot-card', card)
|
||||
setVar('--boot-border', border)
|
||||
setVar('--boot-orb', orb)
|
||||
setVar('--boot-spinner-track', spinnerTrack)
|
||||
setVar('--boot-shadow', shadow)
|
||||
|
||||
if (tenantColor) {
|
||||
setVar('--App-primary-color', tenantColor)
|
||||
}
|
||||
if (tenantSecondary) {
|
||||
setVar('--App-secondary-color', tenantSecondary)
|
||||
}
|
||||
if (tenantBackground) {
|
||||
setVar('--App-background-color', tenantBackground)
|
||||
}
|
||||
|
||||
document.title = user.name_fa || user.name || 'در حال بارگذاری...'
|
||||
}
|
||||
|
||||
applyBootTheme()
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="boot-loader">
|
||||
<div class="boot-loader__card">
|
||||
<div class="boot-loader__orb"></div>
|
||||
<div class="boot-loader__spinner"></div>
|
||||
|
||||
<h1 class="boot-loader__title">در حال هدایت به سامانه</h1>
|
||||
<p class="boot-loader__text">لطفاً چند لحظه صبر کنید...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
;(function () {
|
||||
var BOOT_TIMEOUT_MS = 90000
|
||||
|
||||
var RETRY_KEY = '__APP_BOOT_RETRY_CLICKED__'
|
||||
var RECOVERY_KEY = '__APP_BOOT_RECOVERY_DONE__'
|
||||
|
||||
var CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
|
||||
var CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
|
||||
|
||||
var TOKEN_STORAGE_KEY = 'liman_user_token'
|
||||
var VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
||||
var VERSION_STORAGE_KEY = 'version'
|
||||
var CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||
|
||||
function getProtocol() {
|
||||
return window.location.protocol === 'https:' ? 'https:' : 'http:'
|
||||
}
|
||||
|
||||
function getVersionUrls() {
|
||||
return ['https://nerkh.gold/get_version', 'https://tablo.talasevin.ir/get_version', 'https://abshode-service.montgit.ir/server/get_version']
|
||||
}
|
||||
|
||||
function getConfigUrls() {
|
||||
var protocol = getProtocol()
|
||||
|
||||
return [
|
||||
protocol + '//nerkh.gold/get_user',
|
||||
protocol + '//tablo.talasevin.ir/get_user',
|
||||
protocol + '//abshode-service.montgit.ir/server/get_user',
|
||||
protocol + '//212.23.201.173:8381/get_user',
|
||||
protocol + '//91.212.174.137:4040/server/get_user'
|
||||
]
|
||||
}
|
||||
|
||||
function sortVersionUrls(urls) {
|
||||
var lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
|
||||
|
||||
if (!lastSuccessUrl) {
|
||||
return urls.slice()
|
||||
}
|
||||
|
||||
return [lastSuccessUrl].concat(urls.filter(function (url) {
|
||||
return url !== lastSuccessUrl
|
||||
}))
|
||||
}
|
||||
|
||||
function sortByLastSuccess(urls) {
|
||||
var lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||
|
||||
return urls.slice().sort(function (a, b) {
|
||||
if (a === lastSuccessUrl) return -1
|
||||
if (b === lastSuccessUrl) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeVersionResponse(data) {
|
||||
if (typeof data === 'string' || typeof data === 'number') {
|
||||
return String(data).trim()
|
||||
}
|
||||
|
||||
if (!data) return ''
|
||||
|
||||
if (typeof data.data === 'string' || typeof data.data === 'number') {
|
||||
return String(data.data).trim()
|
||||
}
|
||||
|
||||
var version =
|
||||
data.version ||
|
||||
data.value ||
|
||||
(data.data && typeof data.data === 'object' ? data.data.version : '')
|
||||
|
||||
return version ? String(version).trim() : ''
|
||||
}
|
||||
|
||||
async function fetchJsonOrText(url, timeout) {
|
||||
var controller = new AbortController()
|
||||
|
||||
var timeoutId = window.setTimeout(function () {
|
||||
controller.abort()
|
||||
}, timeout)
|
||||
|
||||
try {
|
||||
var response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed: ' + response.status)
|
||||
}
|
||||
|
||||
var contentType = response.headers.get('content-type') || ''
|
||||
|
||||
if (contentType.indexOf('application/json') !== -1) {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
return await response.text()
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVersionFromUrl(url, timeout) {
|
||||
var controller = new AbortController()
|
||||
var timeoutId = window.setTimeout(function () {
|
||||
controller.abort()
|
||||
}, timeout)
|
||||
|
||||
try {
|
||||
var response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Version request failed: ' + response.status)
|
||||
}
|
||||
|
||||
var contentType = response.headers.get('content-type') || ''
|
||||
var data
|
||||
|
||||
if (contentType.indexOf('application/json') !== -1) {
|
||||
data = await response.json()
|
||||
} else {
|
||||
data = await response.text()
|
||||
}
|
||||
|
||||
var version = normalizeVersionResponse(data)
|
||||
|
||||
if (!version) {
|
||||
throw new Error('Version response is empty')
|
||||
}
|
||||
|
||||
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
|
||||
return version
|
||||
} catch (error) {
|
||||
console.warn('Version failed:', url, error)
|
||||
return undefined
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemoteVersion(rounds) {
|
||||
var urls = sortVersionUrls(getVersionUrls())
|
||||
|
||||
for (var round = 1; round <= rounds; round++) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
var timeout = i === urls.length - 1 ? 6000 : 3500
|
||||
var result = await fetchVersionFromUrl(urls[i], timeout)
|
||||
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchFreshConfig(rounds) {
|
||||
var urls = sortByLastSuccess(getConfigUrls())
|
||||
|
||||
for (var round = 1; round <= rounds; round++) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
var url = urls[i]
|
||||
|
||||
try {
|
||||
var config = await fetchJsonOrText(url, i === urls.length - 1 ? 7000 : 4000)
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('Config response is invalid')
|
||||
}
|
||||
|
||||
return {
|
||||
config: config,
|
||||
url: url
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Config failed:', url, 'round:', round, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Cannot fetch fresh config')
|
||||
}
|
||||
|
||||
function writeConfigCookie(config) {
|
||||
try {
|
||||
var payload = {
|
||||
cached_at: Date.now(),
|
||||
config: config
|
||||
}
|
||||
|
||||
var encoded = encodeURIComponent(JSON.stringify(payload))
|
||||
var secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||
|
||||
document.cookie =
|
||||
CONFIG_COOKIE_NAME +
|
||||
'=' +
|
||||
encoded +
|
||||
'; Path=/; Max-Age=' +
|
||||
CONFIG_COOKIE_MAX_AGE_SECONDS +
|
||||
'; SameSite=Lax' +
|
||||
secure
|
||||
} catch (error) {
|
||||
console.warn('Failed to write config cookie:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearReadableCookies() {
|
||||
document.cookie.split(';').forEach(function (cookie) {
|
||||
var name = cookie.split('=')[0] && cookie.split('=')[0].trim()
|
||||
|
||||
if (!name) return
|
||||
|
||||
document.cookie = name + '=; Path=/; Max-Age=0; SameSite=Lax'
|
||||
})
|
||||
}
|
||||
|
||||
function clearLocalStorageExceptToken() {
|
||||
var token = localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||
|
||||
localStorage.clear()
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearServiceWorkersAndCaches() {
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
var registrations = await navigator.serviceWorker.getRegistrations()
|
||||
|
||||
await Promise.all(
|
||||
registrations.map(function (registration) {
|
||||
return registration.unregister()
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to unregister service workers:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
var keys = await caches.keys()
|
||||
|
||||
await Promise.all(
|
||||
keys.map(function (key) {
|
||||
return caches.delete(key)
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear caches:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function reloadWithCacheBust() {
|
||||
var url = new URL(window.location.href)
|
||||
url.searchParams.set('__boot_recover', String(Date.now()))
|
||||
window.location.replace(url.toString())
|
||||
}
|
||||
|
||||
function renderRecovering(text) {
|
||||
var app = document.getElementById('app')
|
||||
if (!app) return
|
||||
|
||||
app.innerHTML =
|
||||
'<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg); color: var(--boot-text);">' +
|
||||
'<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card); border: 1px solid var(--boot-border); border-radius: 24px; padding: 28px 22px;">' +
|
||||
'<div style="width: 54px; height: 54px; margin: 0 auto 18px; border-radius: 999px; border: 4px solid rgba(255,255,255,0.18); border-top-color: var(--boot-accent); animation: bootSpin 0.9s linear infinite;"></div>' +
|
||||
'<style>@keyframes bootSpin { to { transform: rotate(360deg); } }</style>' +
|
||||
'<h2 style="margin: 0 0 12px; font-size: 20px;">در حال بازیابی برنامه</h2>' +
|
||||
'<p style="margin: 0; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
|
||||
(text || 'لطفاً چند لحظه صبر کنید.') +
|
||||
'</p>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
|
||||
function renderBootError() {
|
||||
var app = document.getElementById('app')
|
||||
if (!app) return
|
||||
|
||||
app.innerHTML =
|
||||
'<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg); color: var(--boot-text);">' +
|
||||
'<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card); border: 1px solid var(--boot-border); border-radius: 24px; padding: 28px 22px;">' +
|
||||
'<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>' +
|
||||
'<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در بارگذاری برنامه</h2>' +
|
||||
'<p style="margin: 0 0 20px; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
|
||||
'ارتباط با سرور برقرار نشد یا فایلهای برنامه بهدرستی دریافت نشدند.' +
|
||||
'</p>' +
|
||||
'<button onclick="window.__APP_BOOT_SIMPLE_RETRY__()" style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent); color: white; cursor: pointer; font-weight: 700;">' +
|
||||
'تلاش مجدد' +
|
||||
'</button>' +
|
||||
'<button onclick="window.__APP_BOOT_CLEAN_RELOAD__()" style="margin-right: 8px; padding: 11px 18px; border: 1px solid var(--boot-border); border-radius: 12px; background: transparent; color: var(--boot-text); cursor: pointer; font-weight: 700;">' +
|
||||
'پاکسازی و تلاش مجدد' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
|
||||
async function cleanFetchAndReload() {
|
||||
renderRecovering('در حال دریافت تنظیمات جدید برنامه...')
|
||||
|
||||
var version = await loadRemoteVersion(5)
|
||||
var freshConfigResult = await fetchFreshConfig(2)
|
||||
|
||||
clearReadableCookies()
|
||||
// clearLocalStorageExceptToken()
|
||||
await clearServiceWorkersAndCaches()
|
||||
|
||||
writeConfigCookie(freshConfigResult.config)
|
||||
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, freshConfigResult.url)
|
||||
|
||||
if (version) {
|
||||
localStorage.setItem(VERSION_STORAGE_KEY, version)
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(RETRY_KEY)
|
||||
sessionStorage.removeItem(RECOVERY_KEY)
|
||||
|
||||
reloadWithCacheBust()
|
||||
}
|
||||
|
||||
window.__APP_BOOT_SIMPLE_RETRY__ = function () {
|
||||
sessionStorage.setItem(RETRY_KEY, '1')
|
||||
reloadWithCacheBust()
|
||||
}
|
||||
|
||||
window.__APP_BOOT_CLEAN_RELOAD__ = async function () {
|
||||
try {
|
||||
sessionStorage.setItem(RECOVERY_KEY, '1')
|
||||
await cleanFetchAndReload()
|
||||
} catch (error) {
|
||||
console.error('Manual boot recovery failed:', error)
|
||||
renderBootError()
|
||||
}
|
||||
}
|
||||
|
||||
window.__BOOT_TIMEOUT__ = window.setTimeout(async function () {
|
||||
if (window.__APP_BOOTSTRAPPED__) return
|
||||
|
||||
var app = document.getElementById('app')
|
||||
var stillBooting = app && app.querySelector('.boot-loader')
|
||||
|
||||
if (!stillBooting) return
|
||||
|
||||
if (!sessionStorage.getItem(RETRY_KEY)) {
|
||||
renderBootError()
|
||||
return
|
||||
}
|
||||
|
||||
if (!sessionStorage.getItem(RECOVERY_KEY)) {
|
||||
sessionStorage.setItem(RECOVERY_KEY, '1')
|
||||
|
||||
try {
|
||||
await cleanFetchAndReload()
|
||||
} catch (error) {
|
||||
console.error('Auto boot recovery after retry failed:', error)
|
||||
renderBootError()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
renderBootError()
|
||||
}, BOOT_TIMEOUT_MS)
|
||||
})()
|
||||
</script>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
window.setTimeout(() => {
|
||||
reject(new Error(message))
|
||||
}, ms)
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
function showBootstrapError(error: unknown) {
|
||||
clearBootTimeout()
|
||||
|
||||
console.error('Bootstrap failed:', error)
|
||||
|
||||
const appRoot = document.querySelector('#app')
|
||||
|
||||
if (!appRoot) return
|
||||
|
||||
appRoot.innerHTML = `
|
||||
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg, #ffffff); color: var(--boot-text, #111827);">
|
||||
<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card, #fff); border: 1px solid var(--boot-border, rgba(15,23,42,0.08)); border-radius: 24px; padding: 28px 22px;">
|
||||
<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>
|
||||
<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در راهاندازی برنامه</h2>
|
||||
<p style="margin: 0 0 20px; color: var(--boot-muted, rgba(15,23,42,0.62)); line-height: 2; font-size: 14px;">
|
||||
لطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید.
|
||||
</p>
|
||||
<button
|
||||
onclick="location.reload()"
|
||||
style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent, #2563eb); color: white; cursor: pointer; font-weight: 700;"
|
||||
>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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<typeof setTimeout> | 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()))
|
||||
}
|
||||
@@ -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<sortedConfigUrls.length-1?2000:5000)
|
||||
if (!response.ok) {
|
||||
} else {
|
||||
localStorage.setItem('CONFIG_Success_URL', sortedConfigUrls[i])
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('e ===>', 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()
|
||||
})()
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { AppConfig } from './types'
|
||||
|
||||
type RawAppConfig = AppConfig & {
|
||||
user?: Record<string, any>
|
||||
}
|
||||
|
||||
let appConfig: RawAppConfig | null = null
|
||||
|
||||
export function setAppConfig(config: RawAppConfig) {
|
||||
appConfig = config || {}
|
||||
window.__APP_CONFIG__ = appConfig
|
||||
}
|
||||
|
||||
export function getRawAppConfig() {
|
||||
return appConfig ?? window.__APP_CONFIG__ ?? null
|
||||
}
|
||||
|
||||
export function getTenantConfig() {
|
||||
const rawConfig = getRawAppConfig()
|
||||
|
||||
if (rawConfig?.user && typeof rawConfig.user === 'object') {
|
||||
return rawConfig.user
|
||||
}
|
||||
|
||||
if (rawConfig && Object.keys(rawConfig).length > 0) {
|
||||
return rawConfig
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export function getConfig<T = any>(key: string, fallback?: T): T {
|
||||
const config = getTenantConfig()
|
||||
const value = config?.[key]
|
||||
|
||||
return (value ?? fallback ?? null) as T
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { getConfig } from '@/config/app-config'
|
||||
import { isPwaManifestReady } from '@/modules/pwa/manifest'
|
||||
|
||||
export function applyAppConfig() {
|
||||
if (isPwaManifestReady()) return
|
||||
|
||||
setTitle()
|
||||
setFavicon()
|
||||
setAppleTouchIconFallback()
|
||||
}
|
||||
|
||||
function setTitle() {
|
||||
document.title = getConfig('name_fa', 'در حال بارگذاری...')
|
||||
}
|
||||
|
||||
function getLogoUrl() {
|
||||
return getConfig<string>('avatar') || '/favicon.png'
|
||||
}
|
||||
|
||||
function setFavicon() {
|
||||
let faviconLink =
|
||||
document.querySelector<HTMLLinkElement>("link[rel='icon']") ||
|
||||
document.querySelector<HTMLLinkElement>("link[rel~='icon']")
|
||||
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement('link')
|
||||
faviconLink.rel = 'icon'
|
||||
document.head.appendChild(faviconLink)
|
||||
}
|
||||
|
||||
const logoUrl = getLogoUrl()
|
||||
|
||||
faviconLink.type = 'image/png'
|
||||
faviconLink.href = logoUrl.startsWith('http') ? logoUrl : resolveRemoteLogo(logoUrl)
|
||||
}
|
||||
|
||||
function setAppleTouchIconFallback() {
|
||||
const logoUrl = getLogoUrl()
|
||||
|
||||
if (!logoUrl) return
|
||||
|
||||
if (document.querySelector("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")) {
|
||||
return
|
||||
}
|
||||
|
||||
const sizes = ['180x180', '152x152', '120x120']
|
||||
|
||||
sizes.forEach((size) => {
|
||||
let link = document.querySelector<HTMLLinkElement>(
|
||||
`link[rel='apple-touch-icon'][sizes='${size}']`
|
||||
)
|
||||
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'apple-touch-icon'
|
||||
link.sizes = size
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
link.href = logoUrl.startsWith('http') ? logoUrl : resolveRemoteLogo(logoUrl)
|
||||
})
|
||||
|
||||
let appleTitle = document.querySelector<HTMLMetaElement>("meta[name='apple-mobile-web-app-title']")
|
||||
|
||||
if (!appleTitle) {
|
||||
appleTitle = document.createElement('meta')
|
||||
appleTitle.name = 'apple-mobile-web-app-title'
|
||||
document.head.appendChild(appleTitle)
|
||||
}
|
||||
|
||||
appleTitle.content = getConfig('name_fa', 'اپ مشتری')
|
||||
}
|
||||
|
||||
function resolveRemoteLogo(path: string) {
|
||||
if (/^(https?:\/\/|data:|blob:)/i.test(path)) return path
|
||||
|
||||
const lastSuccess = localStorage.getItem('CONFIG_Success_URL')
|
||||
const base = lastSuccess
|
||||
? lastSuccess.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
|
||||
: 'https://nerkh.gold'
|
||||
|
||||
return `${base}/${path.replace(/^\/+/, '')}`
|
||||
}
|
||||
@@ -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<AppConfig>
|
||||
) {
|
||||
const normalizedVersion = getNormalizedVersion(newVersion)
|
||||
|
||||
if (!normalizedVersion) {
|
||||
throw new Error('Version is empty')
|
||||
}
|
||||
|
||||
startVersionUpdateLock(normalizedVersion)
|
||||
|
||||
clearCachedAppConfig()
|
||||
clearAppStorageExceptToken()
|
||||
clearAllBootstrapFetchCaches()
|
||||
await clearBrowserRuntimeCaches()
|
||||
|
||||
const freshConfig = await fetchConfig()
|
||||
|
||||
writeCachedAppConfig(freshConfig)
|
||||
finishVersionUpdateBeforeReload(normalizedVersion)
|
||||
|
||||
return freshConfig
|
||||
}
|
||||
|
||||
export async function resetAppAndRefreshConfig(
|
||||
fetchConfig: () => Promise<AppConfig>,
|
||||
version?: string | null
|
||||
) {
|
||||
const normalizedVersion = getNormalizedVersion(version)
|
||||
|
||||
clearCachedAppConfig()
|
||||
clearAppStorageExceptToken()
|
||||
clearAllBootstrapFetchCaches()
|
||||
await clearBrowserRuntimeCaches()
|
||||
|
||||
const freshConfig = await fetchConfig()
|
||||
|
||||
writeCachedAppConfig(freshConfig)
|
||||
|
||||
if (normalizedVersion) {
|
||||
writeStoredAppVersion(normalizedVersion)
|
||||
}
|
||||
|
||||
return freshConfig
|
||||
}
|
||||
@@ -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<AppConfig> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Config request failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error('Config response is not JSON')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchFreshAppConfig(): Promise<AppConfig> {
|
||||
const urls = sortConfigUrls(getConfigUrls())
|
||||
|
||||
for (let index = 0; index < urls.length; index++) {
|
||||
const url = urls[index]
|
||||
|
||||
try {
|
||||
const timeout = index === urls.length - 1 ? 5000 : 2500
|
||||
const config = await fetchConfig(url, timeout)
|
||||
|
||||
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
|
||||
writeCachedAppConfig(config)
|
||||
|
||||
return config
|
||||
} catch (error) {
|
||||
console.warn(`Config failed from ${url}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Cannot load app config from any server')
|
||||
}
|
||||
|
||||
export async function loadAppConfig(): Promise<AppConfig> {
|
||||
const cachedConfig = readCachedAppConfig()
|
||||
|
||||
if (cachedConfig) {
|
||||
return cachedConfig
|
||||
}
|
||||
|
||||
return await fetchFreshAppConfig()
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
type VersionResponse =
|
||||
| string
|
||||
| number
|
||||
| {
|
||||
version?: string | number
|
||||
data?: string | number | { version?: string | number; value?: string | number }
|
||||
value?: string | number
|
||||
user?: unknown
|
||||
}
|
||||
|
||||
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
||||
const VERSION_RETRY_ROUNDS = 5
|
||||
|
||||
let versionFetchPromise: Promise<string | null> | null = null
|
||||
let cachedVersionResult: string | null | undefined
|
||||
|
||||
function getVersionUrls() {
|
||||
return [
|
||||
'https://nerkh.gold/get_version',
|
||||
'https://tablo.talasevin.ir/get_version',
|
||||
'https://abshode-service.montgit.ir/server/get_version'
|
||||
]
|
||||
}
|
||||
|
||||
function sortVersionUrls(urls: string[]) {
|
||||
const lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
|
||||
|
||||
if (!lastSuccessUrl) {
|
||||
return urls
|
||||
}
|
||||
|
||||
const others = urls.filter((url) => url !== lastSuccessUrl)
|
||||
return [lastSuccessUrl, ...others]
|
||||
}
|
||||
|
||||
function readUserVersion(user: unknown) {
|
||||
if (!user || typeof user !== 'object' || Array.isArray(user)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const record = user as { version?: string | number; value?: string | number }
|
||||
const version = record.version ?? record.value
|
||||
|
||||
return version === undefined || version === null ? '' : String(version).trim()
|
||||
}
|
||||
|
||||
function normalizeVersionResponse(response: VersionResponse) {
|
||||
if (typeof response === 'string' || typeof response === 'number') {
|
||||
return String(response).trim()
|
||||
}
|
||||
|
||||
if (!response) return ''
|
||||
|
||||
if (typeof response.data === 'string' || typeof response.data === 'number') {
|
||||
return String(response.data).trim()
|
||||
}
|
||||
|
||||
const directVersion =
|
||||
response.version ??
|
||||
response.value ??
|
||||
(typeof response.data === 'object' ? response.data?.version ?? response.data?.value : undefined)
|
||||
|
||||
if (directVersion !== undefined && directVersion !== null) {
|
||||
return String(directVersion).trim()
|
||||
}
|
||||
|
||||
return readUserVersion(response.user)
|
||||
}
|
||||
|
||||
async function fetchVersion(url: string, timeout = 3500): Promise<string | null> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Non-2xx/timeout should be treated as "request failed" and we may try the next URL.
|
||||
throw new Error(`Version request failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
|
||||
const data = contentType.includes('application/json')
|
||||
? ((await response.json()) as VersionResponse)
|
||||
: ((await response.text()) as VersionResponse)
|
||||
|
||||
const version = normalizeVersionResponse(data)
|
||||
|
||||
// IMPORTANT: if the request returned 200/ok, we stop here even if we couldn't extract a version.
|
||||
// This prevents unnecessary looping when the endpoint is "reachable" but returns an empty payload.
|
||||
if (!version) {
|
||||
return null
|
||||
}
|
||||
|
||||
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
|
||||
|
||||
return version
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<string | null> {
|
||||
const urls = sortVersionUrls(getVersionUrls())
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
for (let index = 0; index < urls.length; index++) {
|
||||
const url = urls[index]
|
||||
const timeout = index === urls.length - 1 ? 6000 : 3500
|
||||
|
||||
try {
|
||||
const version = await fetchVersion(url, timeout)
|
||||
// fetchVersion only throws on real request failures (non-ok / timeout).
|
||||
// If it returns null, we got 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<string | null> {
|
||||
const rounds = options?.rounds ?? VERSION_RETRY_ROUNDS
|
||||
const force = options?.force ?? false
|
||||
|
||||
if (force) {
|
||||
resetVersionCheckCache()
|
||||
}
|
||||
|
||||
if (cachedVersionResult !== undefined) {
|
||||
return cachedVersionResult
|
||||
}
|
||||
|
||||
if (versionFetchPromise) {
|
||||
return versionFetchPromise
|
||||
}
|
||||
|
||||
versionFetchPromise = fetchVersionWithRetry(rounds)
|
||||
.then((version) => {
|
||||
cachedVersionResult = version
|
||||
return version
|
||||
})
|
||||
.finally(() => {
|
||||
versionFetchPromise = null
|
||||
})
|
||||
|
||||
return versionFetchPromise
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface AppConfig {
|
||||
mode?: string
|
||||
theme?: string
|
||||
name?: string
|
||||
name_fa?: string
|
||||
|
||||
avatar?: string
|
||||
color?: string
|
||||
background_color?: string
|
||||
secondary_color?: string
|
||||
|
||||
node_server?: string
|
||||
node_ws?: string
|
||||
|
||||
manifest_url?: string
|
||||
|
||||
user?: Record<string, any>
|
||||
|
||||
[key: string]: any
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<MobileOverflowMenu
|
||||
:visible="popVisible"
|
||||
:overflow-items="overflowItems"
|
||||
@close="closeOverflowMenu"
|
||||
@navigate="navigateTo"
|
||||
/>
|
||||
|
||||
<div :class="['main-with-bottom-chrome', { 'main-with-bottom-chrome--no-nav': hideMenu }]">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition :name="route.meta.transition || 'fade'">
|
||||
@@ -21,7 +14,6 @@
|
||||
:nav-logo="navLogo"
|
||||
:nav-item-class="navItemClass"
|
||||
@navigate="navigateTo"
|
||||
@toggle-overflow="toggleOverflowMenu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="less" src="./mobile-layout.less"></style>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
class="circle_in"
|
||||
:class="isWhiteNavLogo ? 'is-white-nav-logo' : 'text-white'"
|
||||
>
|
||||
<div class="circle_into" @click.stop="$emit('toggle-overflow')">
|
||||
<div class="circle_into">
|
||||
<div
|
||||
v-if="projectName === 'garoosi'"
|
||||
class="p-2 text-tertiary flex flex-col justify-center items-center"
|
||||
@@ -126,7 +126,6 @@ defineProps<{
|
||||
|
||||
defineEmits<{
|
||||
navigate: [routeName: string]
|
||||
'toggle-overflow': []
|
||||
}>()
|
||||
|
||||
const projectName = getConfigApp('name')
|
||||
|
||||
+9
-202
@@ -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<typeof setTimeout> | null = null
|
||||
let hasFingerPrint: boolean = false
|
||||
const userInfo = ref()
|
||||
// userInfo.value = await getMe()
|
||||
// const token = getToken()
|
||||
|
||||
const currentRoute = computed(() => {
|
||||
return router.currentRoute.value.fullPath
|
||||
})
|
||||
|
||||
async function start() {
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<string>('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<PwaManifest> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/manifest+json, application/json' },
|
||||
cache: 'default',
|
||||
signal: controller.signal,
|
||||
priority: 'high'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Manifest request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
|
||||
if (!isValidManifest(payload)) {
|
||||
throw new Error('Manifest response is missing valid icons')
|
||||
}
|
||||
|
||||
return payload
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
/** retry فقط روی همان URL — بدون درخواست دوم */
|
||||
async function fetchManifestWithRetry(url: string): Promise<PwaManifest> {
|
||||
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<string, string>) {
|
||||
const selectorParts = Object.entries(attrs)
|
||||
.filter(([key]) => key !== 'href')
|
||||
.map(([key, value]) => `[${key}="${value}"]`)
|
||||
.join('')
|
||||
|
||||
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]${selectorParts}`)
|
||||
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = rel
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
Object.entries(attrs).forEach(([key, value]) => {
|
||||
link!.setAttribute(key, value)
|
||||
})
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
function upsertMeta(name: string, content: string) {
|
||||
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
||||
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta')
|
||||
meta.name = name
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
|
||||
meta.content = content
|
||||
}
|
||||
|
||||
function removeAllManifestLinks() {
|
||||
document.querySelectorAll<HTMLLinkElement>("link[rel='manifest']").forEach((node) => node.remove())
|
||||
}
|
||||
|
||||
function removeDynamicManifestLinks() {
|
||||
removeAllManifestLinks()
|
||||
revokeManifestBlobUrl()
|
||||
|
||||
document
|
||||
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
|
||||
.forEach((node) => node.remove())
|
||||
}
|
||||
|
||||
function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
|
||||
document
|
||||
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
|
||||
.forEach((node) => node.remove())
|
||||
|
||||
const appleSizes = [180, 167, 152, 120]
|
||||
const used = new Set<string>()
|
||||
|
||||
appleSizes.forEach((size) => {
|
||||
const href = pickManifestIconUrl(manifest, [size, 192, 512])
|
||||
|
||||
if (!href || used.has(href)) return
|
||||
|
||||
used.add(href)
|
||||
|
||||
upsertLink('apple-touch-icon', {
|
||||
'data-dynamic-pwa': 'true',
|
||||
sizes: `${size}x${size}`,
|
||||
href
|
||||
})
|
||||
})
|
||||
|
||||
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
|
||||
|
||||
if (primary) {
|
||||
upsertLink('apple-touch-icon', {
|
||||
'data-dynamic-pwa': 'true',
|
||||
href: primary
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyPreparedManifestToDocument(
|
||||
normalized: PwaManifest,
|
||||
manifestUrl: string
|
||||
) {
|
||||
removeDynamicManifestLinks()
|
||||
|
||||
if (!normalized.icons?.length) {
|
||||
throw new Error('Manifest has no valid absolute icon URLs')
|
||||
}
|
||||
|
||||
const title = normalized.short_name || normalized.name
|
||||
|
||||
if (!title) {
|
||||
throw new Error('Prepared manifest is missing title')
|
||||
}
|
||||
|
||||
document.title = title
|
||||
|
||||
const blob = new Blob([JSON.stringify(normalized)], {
|
||||
type: 'application/manifest+json'
|
||||
})
|
||||
manifestBlobUrl = URL.createObjectURL(blob)
|
||||
|
||||
upsertLink('manifest', {
|
||||
'data-dynamic-pwa': 'true',
|
||||
href: manifestBlobUrl
|
||||
})
|
||||
|
||||
if (normalized.theme_color) {
|
||||
upsertMeta('theme-color', normalized.theme_color)
|
||||
}
|
||||
|
||||
upsertMeta('apple-mobile-web-app-title', title)
|
||||
upsertMeta('mobile-web-app-capable', 'yes')
|
||||
upsertMeta('apple-mobile-web-app-capable', 'yes')
|
||||
|
||||
applyAppleTouchIconsFromManifest(normalized)
|
||||
|
||||
const favicon = pickManifestIconUrl(normalized, [192, 180, 128, 96, 72])
|
||||
|
||||
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
|
||||
|
||||
if (favicon) {
|
||||
upsertLink('icon', { type: 'image/png', href: favicon })
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyManifestToDocument(manifest: PwaManifest, manifestUrl: string) {
|
||||
const prepared = prepareManifestForInstall(manifest)
|
||||
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
||||
}
|
||||
|
||||
export function setPwaManifestReady(enabled: boolean) {
|
||||
if (enabled) {
|
||||
sessionStorage.setItem(PWA_MANIFEST_READY_KEY, '1')
|
||||
} else {
|
||||
sessionStorage.removeItem(PWA_MANIFEST_READY_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export function isPwaManifestReady() {
|
||||
return sessionStorage.getItem(PWA_MANIFEST_READY_KEY) === '1'
|
||||
}
|
||||
|
||||
/** هرچه زودتر در main.ts — قبل از bootstrap */
|
||||
export function initPwaInstallCapture() {
|
||||
if (installCaptureInitialized || typeof window === 'undefined') return
|
||||
installCaptureInitialized = true
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (isPwaManifestReady()) {
|
||||
notifyPwaManifestReady(e)
|
||||
return
|
||||
}
|
||||
|
||||
pendingBeforeInstallPrompt = e
|
||||
console.info('PWA install prompt captured, waiting for manifest + service worker')
|
||||
})
|
||||
}
|
||||
|
||||
export function notifyPwaManifestReady(installEvent?: Event | null) {
|
||||
if (!isPwaManifestReady()) return
|
||||
|
||||
const event = installEvent ?? pendingBeforeInstallPrompt
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(PWA_MANIFEST_READY_EVENT, {
|
||||
detail: { installEvent: event }
|
||||
})
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
console.info('PWA ready (manifest + SW). Waiting for beforeinstallprompt…', {
|
||||
secureContext: window.isSecureContext,
|
||||
origin: window.location.origin
|
||||
})
|
||||
}
|
||||
|
||||
pendingBeforeInstallPrompt = null
|
||||
}
|
||||
|
||||
async function applyCachedManifestEntry(
|
||||
cached: ReturnType<typeof readCachedPwaManifest>
|
||||
): Promise<boolean> {
|
||||
if (!cached) return false
|
||||
|
||||
try {
|
||||
const prepared = prepareManifestForInstall(cached.manifest)
|
||||
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
|
||||
setPwaManifestReady(true)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Cached PWA manifest invalid, refetching:', error)
|
||||
clearCachedPwaManifest()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* بعد از loadAppConfig / setAppConfig صدا بزن.
|
||||
* get_user دوباره fetch نمیشود — فقط monifist.json از base کششده.
|
||||
*/
|
||||
export async function setupPwaManifest(config?: AppConfig | null): Promise<boolean> {
|
||||
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())
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="version-update-fade">
|
||||
<div v-if="isUpdating || hasError" class="version-update-overlay" dir="rtl">
|
||||
<div class="version-update-card">
|
||||
<div v-if="!hasError" class="version-update-spinner" />
|
||||
|
||||
<div v-else class="version-update-error-icon">!</div>
|
||||
|
||||
<h2 class="version-update-title">
|
||||
{{ hasError ? 'خطا در بهروزرسانی' : 'در حال بهروزرسانی برنامه' }}
|
||||
</h2>
|
||||
|
||||
<p class="version-update-text">
|
||||
{{ statusText || 'لطفاً چند لحظه صبر کنید...' }}
|
||||
</p>
|
||||
|
||||
<button v-if="hasError" type="button" class="version-update-button" @click="retry">
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { setAppConfig } from '@/config/app-config'
|
||||
import { fetchFreshAppConfig } from '@/config/load-config'
|
||||
import { loadRemoteAppVersion, resetVersionCheckCache } from '@/config/load-version'
|
||||
import {
|
||||
shouldUpdateForVersion,
|
||||
canStartVersionUpdate,
|
||||
refreshConfigCacheForVersion,
|
||||
cancelVersionUpdateLock,
|
||||
hardReloadApp
|
||||
} from '@/config/config-cache'
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const hasError = ref(false)
|
||||
const statusText = ref('')
|
||||
const pendingVersion = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await checkRemoteVersion()
|
||||
})
|
||||
|
||||
async function checkRemoteVersion() {
|
||||
try {
|
||||
const version = await loadRemoteAppVersion()
|
||||
|
||||
/**
|
||||
* خیلی مهم:
|
||||
* اگر get_version جواب نداد، هیچ کاری نکن.
|
||||
* نه cache پاک کن، نه reload بزن.
|
||||
* چون ممکنه کاربر فقط اینترنت ضعیف داشته باشه یا endpoint موقتاً 404/timeout بده.
|
||||
*/
|
||||
if (!version) {
|
||||
console.warn('Remote version is empty. Version check skipped.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldUpdateForVersion(version)) return
|
||||
if (!canStartVersionUpdate(version)) return
|
||||
|
||||
pendingVersion.value = version
|
||||
|
||||
await startUpdate(version)
|
||||
} catch (error) {
|
||||
console.warn('Version check failed. Version update skipped:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function startUpdate(version: string) {
|
||||
try {
|
||||
isUpdating.value = true
|
||||
hasError.value = false
|
||||
|
||||
statusText.value = 'در حال آمادهسازی نسخه جدید...'
|
||||
await wait(500)
|
||||
|
||||
statusText.value = 'دریافت تنظیمات جدید برنامه...'
|
||||
|
||||
const freshConfig = await refreshConfigCacheForVersion(version, fetchFreshAppConfig)
|
||||
|
||||
setAppConfig(freshConfig)
|
||||
|
||||
await wait(500)
|
||||
|
||||
statusText.value = 'بارگذاری مجدد برنامه...'
|
||||
await wait(300)
|
||||
|
||||
hardReloadApp()
|
||||
} catch (error) {
|
||||
console.error('Version update failed:', error)
|
||||
|
||||
cancelVersionUpdateLock()
|
||||
|
||||
isUpdating.value = false
|
||||
hasError.value = true
|
||||
statusText.value = 'بهروزرسانی انجام نشد. لطفاً دوباره تلاش کنید.'
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
hasError.value = false
|
||||
|
||||
if (pendingVersion.value) {
|
||||
startUpdate(pendingVersion.value)
|
||||
return
|
||||
}
|
||||
|
||||
resetVersionCheckCache()
|
||||
void checkRemoteVersion()
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version-update-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: radial-gradient(
|
||||
circle at top,
|
||||
rgba(var(--colorPrimaryRGB, 37 99 235) / 0.16),
|
||||
transparent 38%
|
||||
),
|
||||
rgba(15, 23, 42, 0.38);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.version-update-card {
|
||||
width: min(390px, 100%);
|
||||
padding: 30px 22px 26px;
|
||||
border-radius: 24px;
|
||||
text-align: center;
|
||||
color: var(--colorText, #111827);
|
||||
background: var(--bg, #ffffff);
|
||||
border: 1px solid rgba(var(--colorPrimaryRGB, 37 99 235) / 0.18);
|
||||
box-shadow: 0 22px 70px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.version-update-spinner {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 999px;
|
||||
border: 4px solid rgba(var(--colorPrimaryRGB, 37 99 235) / 0.14);
|
||||
border-top-color: var(--colorPrimary, #2563eb);
|
||||
animation: version-update-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.version-update-error-icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color6, #ffffff);
|
||||
background: var(--color5, #f44336);
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.version-update-title {
|
||||
margin: 0;
|
||||
color: var(--colorText, #111827);
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.version-update-text {
|
||||
margin: 12px 0 0;
|
||||
color: var(--colorText, #111827);
|
||||
opacity: 0.72;
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.version-update-button {
|
||||
width: 100%;
|
||||
margin-top: 22px;
|
||||
padding: 12px 18px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
color: var(--color6, #ffffff);
|
||||
background: var(--colorPrimary, #2563eb);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-update-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.version-update-fade-enter-active,
|
||||
.version-update-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.version-update-fade-enter-from,
|
||||
.version-update-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes version-update-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+40
-25
@@ -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,
|
||||
}
|
||||
|
||||
+3
-2
@@ -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}`
|
||||
}
|
||||
|
||||
+4
-4
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface SupportMessengerSettings {
|
||||
export const supportMessengerSettings = ref<SupportMessengerSettings>({})
|
||||
|
||||
export const DEFAULT_SUPPORT_MESSENGER_TEXT =
|
||||
'جهت شارژ حساب کاربری خود، مبلغ موردنظر را واریز نموده و از طریق پیامرسان به پشتیبانی اطلاع دهید.'
|
||||
'جهت شارژ حساب کاربری خود، کلمه «شماره حساب» را برای پشتیبانی ارسال نمایید.'
|
||||
|
||||
function getSupportSettingValue(
|
||||
settings: Array<{ key?: string; is_active?: boolean | number; value?: unknown }>,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -39,6 +39,17 @@
|
||||
@click="$emit('openGoldModal', primaryMetalTotal)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="[rowClass, 'mt-2']" class="flex justify-center w-full z-20">
|
||||
دارایی ریالی:
|
||||
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(cashWallet) }} ریال</div>
|
||||
<BasicIcon
|
||||
icon="vuesax-linear:info-circle"
|
||||
size="18"
|
||||
:color="iconColor"
|
||||
@click="$emit('openRialModal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!boursecuLayout">
|
||||
@@ -61,7 +72,7 @@
|
||||
|
||||
<div :class="[rowClass, 'mt-2']" class="flex justify-center w-full z-20">
|
||||
دارایی ریالی:
|
||||
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(totalRialValue) }} ریال</div>
|
||||
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(cashWallet) }} ریال</div>
|
||||
<BasicIcon
|
||||
icon="vuesax-linear:info-circle"
|
||||
size="18"
|
||||
@@ -119,7 +130,7 @@ const primaryMetalTotal = computed(() => metalTotals.value[0])
|
||||
const boursecuAssetRows = computed(() =>
|
||||
props.boursecuLayout ? buildBoursecuHomeAssetRows(assetsSummary.value) : []
|
||||
)
|
||||
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
|
||||
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
|
||||
const inventoryCards = computed(() => assetsSummary.value?.inventory_cards ?? [])
|
||||
const countBasedInventory = computed(() =>
|
||||
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, h, inject, onMounted, onUnmounted, provide, ref, type InjectionKey } from 'vue'
|
||||
import { computed, h, inject, onUnmounted, provide, ref, type InjectionKey } from 'vue'
|
||||
import router from '@/router'
|
||||
import { loading, removeToken } from '@/utils'
|
||||
import { getToken, removeToken } from '@/utils'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useWalletStore } from '@/store/walletStore'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
@@ -20,14 +20,14 @@ import { useBanners } from './useBanners'
|
||||
import { usePriceChart, type ChartMetalType } from './usePriceChart'
|
||||
import type { HomeActionItem } from '../layouts/types'
|
||||
import { getHomeLayoutId } from '../layouts/registry'
|
||||
import { initHomePageData, resetHomePageData } from './useHomePageData'
|
||||
import { initHomePageData, resetHomePageData, useHomePageLoading } from './useHomePageData'
|
||||
import { resetWalletPageData } from '../../wallet/composables/useWalletPageData'
|
||||
import { resetAddressPageData } from '../../address/composables/useAddressPageData'
|
||||
import { resetTransactionsPageData } from '../../transactions/composables/useTransactionsPageData'
|
||||
import { resetProductsPageData } from '../../products/composables/useProductsPageData'
|
||||
|
||||
export interface HomePageContext {
|
||||
loading: typeof loading
|
||||
homePageLoading: ReturnType<typeof useHomePageLoading>['homePageLoading']
|
||||
isGaroosi: ReturnType<typeof computed<boolean>>
|
||||
limitedPrice: ReturnType<typeof computed>
|
||||
showClosedMessage: ReturnType<typeof computed<boolean>>
|
||||
@@ -65,6 +65,7 @@ export function useHomePage() {
|
||||
const TextBanner = ref('')
|
||||
|
||||
const { banners } = useBanners()
|
||||
const { homePageLoading } = useHomePageLoading()
|
||||
const {
|
||||
updateChartData,
|
||||
priceChartSeries,
|
||||
@@ -230,7 +231,7 @@ export function useHomePage() {
|
||||
}
|
||||
|
||||
const context: HomePageContext = {
|
||||
loading,
|
||||
homePageLoading,
|
||||
isGaroosi,
|
||||
limitedPrice,
|
||||
showClosedMessage,
|
||||
@@ -250,15 +251,12 @@ export function useHomePage() {
|
||||
|
||||
provide(HOME_PAGE_CONTEXT_KEY, context)
|
||||
|
||||
void ensureHomeUserAndData()
|
||||
|
||||
onUnmounted(() => {
|
||||
stopLastPricePolling()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Single shared fetch for mobile + desktop — safe to call on remount (deduped in useHomePageData)
|
||||
void initHomePageData()
|
||||
})
|
||||
|
||||
return {
|
||||
userStore,
|
||||
walletStore,
|
||||
@@ -279,3 +277,15 @@ export function useHomePageContext() {
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
async function ensureHomeUserAndData() {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (getToken() && !userStore.userInfo) {
|
||||
await userStore.getMyInfo()
|
||||
}
|
||||
|
||||
if (getToken()) {
|
||||
await initHomePageData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { loading } from '@/utils'
|
||||
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
|
||||
import { useWalletAssets } from '@/composables/useWalletAssets'
|
||||
import { clearProductsCatalog } from '@/composables/useProductsCatalog'
|
||||
@@ -8,13 +7,16 @@ import { fetchHomeChartPrices, loadHomeMaftolOffset, resetHomePriceChart } from
|
||||
import { loadHomeBanners, resetHomeBanners } from './useBanners'
|
||||
import { loadHomeFunds, resetHomeFunds } from './useUserFunds'
|
||||
|
||||
/**
|
||||
* Single entry point for all homepage API calls.
|
||||
* Mobile and desktop layouts must not fetch independently — they consume shared module state.
|
||||
*/
|
||||
/** فقط لودینگ صفحه اصلی — نه loading سراسری @/utils */
|
||||
const homePageLoading = ref(false)
|
||||
|
||||
let initPromise: Promise<void> | null = null
|
||||
const initialized = ref(false)
|
||||
|
||||
export function useHomePageLoading() {
|
||||
return { homePageLoading }
|
||||
}
|
||||
|
||||
export function isHomePageDataInitialized() {
|
||||
return initialized.value
|
||||
}
|
||||
@@ -36,7 +38,6 @@ export async function initHomePageData(force = false) {
|
||||
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { loadWalletAssets } = useWalletAssets()
|
||||
|
||||
await Promise.all([
|
||||
@@ -56,7 +57,6 @@ export async function initHomePageData(force = false) {
|
||||
} catch (error) {
|
||||
console.error('Home page initialization error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
initPromise = null
|
||||
}
|
||||
})()
|
||||
@@ -68,6 +68,7 @@ export async function initHomePageData(force = false) {
|
||||
export function resetHomePageData() {
|
||||
initialized.value = false
|
||||
initPromise = null
|
||||
homePageLoading.value = false
|
||||
stopLastPricePolling()
|
||||
resetHomeBanners()
|
||||
resetHomeFunds()
|
||||
|
||||
@@ -19,7 +19,7 @@ const isBoursecuLayout = computed(() => ctx.isBoursecuHomeLayout.value)
|
||||
const meta = computed(() => assetsSummary.value?.meta)
|
||||
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
|
||||
const inventoryCards = computed(() => assetsSummary.value?.inventory_cards ?? [])
|
||||
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
|
||||
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
|
||||
const countBasedInventory = computed(() =>
|
||||
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0),
|
||||
)
|
||||
@@ -90,7 +90,7 @@ const metrics = computed<MetricItem[]>(() => {
|
||||
items.push({
|
||||
id: 'rial',
|
||||
label: 'دارایی ریالی',
|
||||
value: `${usePriceWithoutType(totalRialValue.value)} ریال`,
|
||||
value: `${usePriceWithoutType(cashWallet.value)} ریال`,
|
||||
showInfo: true,
|
||||
onInfo: 'rial',
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { loading } from '@/utils'
|
||||
import { getToken } from '@/utils'
|
||||
import { useHomePageContext } from '../../composables/useHomePage'
|
||||
import HomeSkeleton from '../../components/HomeSkeleton.vue'
|
||||
import DesktopHomeShell from '../components/DesktopHomeShell.vue'
|
||||
@@ -18,10 +19,11 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { userInfo } = storeToRefs(userStore)
|
||||
const ctx = useHomePageContext()
|
||||
|
||||
const showSkeleton = computed(() => loading.value || !userStore.userInfo)
|
||||
const showContent = computed(() => !loading.value && Boolean(userStore.userInfo))
|
||||
const showContent = computed(() => Boolean(userInfo.value))
|
||||
const showSkeleton = computed(() => Boolean(getToken()) && !userInfo.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,7 +33,7 @@ const showContent = computed(() => !loading.value && Boolean(userStore.userInfo)
|
||||
|
||||
<HomeSkeleton v-if="showSkeleton" />
|
||||
|
||||
<template v-else-if="showContent">
|
||||
<template v-if="showContent">
|
||||
<section class="dhome-hero-row">
|
||||
<DesktopLivePrice />
|
||||
<DesktopPriceRange />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { loading } from '@/utils'
|
||||
import { getToken } from '@/utils'
|
||||
import { useHomePageContext } from '../../composables/useHomePage'
|
||||
import HomeSkeleton from '../../components/HomeSkeleton.vue'
|
||||
import DesktopHomeShell from '../components/DesktopHomeShell.vue'
|
||||
@@ -21,10 +22,11 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { userInfo } = storeToRefs(userStore)
|
||||
const ctx = useHomePageContext()
|
||||
|
||||
const showSkeleton = computed(() => loading.value || !userStore.userInfo)
|
||||
const showContent = computed(() => !loading.value && Boolean(userStore.userInfo))
|
||||
const showContent = computed(() => Boolean(userInfo.value))
|
||||
const showSkeleton = computed(() => Boolean(getToken()) && !userInfo.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,7 +36,7 @@ const showContent = computed(() => !loading.value && Boolean(userStore.userInfo)
|
||||
|
||||
<HomeSkeleton v-if="showSkeleton" />
|
||||
|
||||
<template v-else-if="showContent">
|
||||
<template v-if="showContent">
|
||||
<section class="dhome-hero-row">
|
||||
<DesktopLivePrice />
|
||||
<DesktopPriceRange />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { loading } from '@/utils'
|
||||
import { getToken } from '@/utils'
|
||||
import HomeSkeleton from '../components/HomeSkeleton.vue'
|
||||
import { getHomeLayoutConfig } from './registry'
|
||||
import { HOME_SECTION_COMPONENTS } from './sections'
|
||||
@@ -12,6 +13,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { userInfo } = storeToRefs(userStore)
|
||||
const layoutConfig = computed(() => props.layoutConfig ?? getHomeLayoutConfig())
|
||||
|
||||
const outerSections = computed(() =>
|
||||
@@ -22,13 +24,17 @@ const innerSections = computed(() =>
|
||||
layoutConfig.value.sections.filter((section) => section.visible !== false)
|
||||
)
|
||||
|
||||
/** محتوا با userInfo — دادههای API در پسزمینه لود میشوند */
|
||||
const showContent = computed(() => Boolean(userInfo.value))
|
||||
const showSkeleton = computed(() => Boolean(getToken()) && !userInfo.value)
|
||||
|
||||
function resolveSectionComponent(sectionId: HomeSectionId) {
|
||||
return HOME_SECTION_COMPONENTS[sectionId]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="userStore.userInfo && !loading">
|
||||
<div v-if="showContent">
|
||||
<component
|
||||
:is="resolveSectionComponent(section.id)"
|
||||
v-for="section in outerSections"
|
||||
@@ -52,5 +58,5 @@ function resolveSectionComponent(sectionId: HomeSectionId) {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<HomeSkeleton v-else class="w-full" />
|
||||
<HomeSkeleton v-else-if="showSkeleton" class="w-full" />
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import type { Component } from 'vue'
|
||||
import { defineAsyncComponent, type Component } from 'vue'
|
||||
import { getConfigApp, isBoursecuProject } from '@/utils/config'
|
||||
import type { HomeLayoutConfig, HomeLayoutId } from './types'
|
||||
import { defaultHomeLayoutConfig } from './configs/default'
|
||||
import { boursecuHomeLayoutConfig } from './configs/boursecu'
|
||||
import HomeLayoutRenderer from './HomeLayoutRenderer.vue'
|
||||
|
||||
export const DEFAULT_HOME_LAYOUT_ID: HomeLayoutId = 'default'
|
||||
|
||||
const HOME_LAYOUT_CONFIGS: Record<HomeLayoutId, HomeLayoutConfig> = {
|
||||
default: defaultHomeLayoutConfig,
|
||||
boursecu: boursecuHomeLayoutConfig,
|
||||
}
|
||||
|
||||
const HOME_LAYOUT_COMPONENTS: Record<HomeLayoutId, Component> = {
|
||||
default: HomeLayoutRenderer,
|
||||
boursecu: HomeLayoutRenderer,
|
||||
}
|
||||
let cachedLayoutRenderer: Component | null = null
|
||||
|
||||
export const DEFAULT_HOME_LAYOUT_ID: HomeLayoutId = 'default'
|
||||
function getLayoutRenderer(): Component {
|
||||
if (!cachedLayoutRenderer) {
|
||||
cachedLayoutRenderer = defineAsyncComponent(() => import('./HomeLayoutRenderer.vue'))
|
||||
}
|
||||
|
||||
return cachedLayoutRenderer
|
||||
}
|
||||
|
||||
export function isHomeLayoutId(value: unknown): value is HomeLayoutId {
|
||||
return typeof value === 'string' && value in HOME_LAYOUT_CONFIGS
|
||||
@@ -38,8 +42,8 @@ export function getHomeLayoutConfig(layoutId = getHomeLayoutId()): HomeLayoutCon
|
||||
return HOME_LAYOUT_CONFIGS[layoutId] ?? HOME_LAYOUT_CONFIGS[DEFAULT_HOME_LAYOUT_ID]
|
||||
}
|
||||
|
||||
export function resolveHomeLayoutComponent(layoutId = getHomeLayoutId()): Component {
|
||||
return HOME_LAYOUT_COMPONENTS[layoutId] ?? HOME_LAYOUT_COMPONENTS[DEFAULT_HOME_LAYOUT_ID]
|
||||
export function resolveHomeLayoutComponent(_layoutId = getHomeLayoutId()): Component {
|
||||
return getLayoutRenderer()
|
||||
}
|
||||
|
||||
export function registerHomeLayoutConfig(layoutId: HomeLayoutId, config: HomeLayoutConfig) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-model:visible="localVisible"
|
||||
custom-footer
|
||||
:closable="false"
|
||||
wrap-class-name="deposit-guide-modal-wrap"
|
||||
width="min(92vw, 380px)"
|
||||
>
|
||||
<div class="deposit-guide">
|
||||
<div class="deposit-guide__icon-wrap" aria-hidden="true">
|
||||
<BasicIcon icon="vuesax-linear:empty-wallet-add" size="34" color="#16a34a" />
|
||||
</div>
|
||||
|
||||
<h2 class="deposit-guide__title">{{ title }}</h2>
|
||||
|
||||
<div class="deposit-guide__body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="supportLink"
|
||||
class="deposit-guide__support-link"
|
||||
:href="supportLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="deposit-guide__support-btn">
|
||||
<BasicIcon icon="vuesax-linear:headphone" size="18" color="#16a34a" />
|
||||
<span>پیام به پشتیبانی</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="smsLink"
|
||||
class="deposit-guide__support-link"
|
||||
:class="{ 'deposit-guide__support-link--spaced': supportLink }"
|
||||
:href="smsLink"
|
||||
>
|
||||
<span class="deposit-guide__support-btn">
|
||||
<BasicIcon icon="vuesax-linear:direct-inbox" size="18" color="#16a34a" />
|
||||
<span>پیام به پشتیبانی (sms)</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BasicButton type="primary" class="deposit-guide__confirm w-full" @click="localVisible = false">
|
||||
متوجه شدم
|
||||
</BasicButton>
|
||||
</template>
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
title?: string
|
||||
supportLink?: string
|
||||
smsLink?: string
|
||||
}>(),
|
||||
{
|
||||
title: 'راهنمای شارژ حساب',
|
||||
supportLink: '',
|
||||
smsLink: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
const localVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value: boolean) => emit('update:visible', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.deposit-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 0.25rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.deposit-guide__icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
border-radius: 9999px;
|
||||
background: #ecfdf3;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.deposit-guide__title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.deposit-guide__body {
|
||||
width: 100%;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.9;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.deposit-guide__support-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 1.25rem;
|
||||
text-decoration: none;
|
||||
|
||||
&--spaced {
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
.deposit-guide__support-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 0.75rem;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.deposit-guide__confirm {
|
||||
min-height: 2.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.deposit-guide-modal-wrap {
|
||||
.ant-modal-content {
|
||||
border-radius: 1rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -51,7 +51,7 @@ export interface WalletPageContext {
|
||||
mobileDepositScrollEl: Ref<HTMLElement | null>
|
||||
desktopDepositScrollEl: Ref<HTMLElement | null>
|
||||
supportMessengerText: ReturnType<typeof computed<string>>
|
||||
supportMessengerLink: ReturnType<typeof computed<string | undefined>>
|
||||
depositGuideSupportLink: ReturnType<typeof computed<string>>
|
||||
depositSmsSupportLink: ReturnType<typeof computed<string>>
|
||||
isBazretalaProject: typeof isBazretalaProject
|
||||
usePriceWithoutType: typeof usePriceWithoutType
|
||||
@@ -91,12 +91,16 @@ export function useWalletPage() {
|
||||
() => supportMessengerSettings.value.messengerText || DEFAULT_SUPPORT_MESSENGER_TEXT,
|
||||
)
|
||||
|
||||
const supportMessengerLink = computed(() =>
|
||||
buildSupportMessengerUrl(
|
||||
const depositGuideSupportLink = computed(() => {
|
||||
if (config.config?.name === 'garoosi') {
|
||||
return 'https://ble.ir/garoosigallery'
|
||||
}
|
||||
|
||||
return buildSupportMessengerUrl(
|
||||
supportMessengerSettings.value.messenger,
|
||||
supportMessengerSettings.value.messengerId,
|
||||
),
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const depositSmsSupportLink = computed(() =>
|
||||
buildDepositSmsUrl(supportMessengerSettings.value.depositSmsSupportNumber),
|
||||
@@ -270,7 +274,7 @@ export function useWalletPage() {
|
||||
mobileDepositScrollEl,
|
||||
desktopDepositScrollEl,
|
||||
supportMessengerText,
|
||||
supportMessengerLink,
|
||||
depositGuideSupportLink,
|
||||
depositSmsSupportLink,
|
||||
isBazretalaProject,
|
||||
usePriceWithoutType,
|
||||
|
||||
@@ -69,58 +69,30 @@
|
||||
|
||||
<SkeletonLoading v-model:show="infoVisible" />
|
||||
|
||||
<BasicModal v-model:visible="chargeAccountModal" custom-footer :closable="false">
|
||||
<div v-if="config.config.name === 'garoosi'" class="text-center">
|
||||
<div class="font-bold text-red-600">مشتریان گرامی</div>
|
||||
<div class="mt-2">
|
||||
<span>جهت شارژ حساب کاربری خود، مبلغ موردنظر را به شماره کارت زیر واریز نموده و از طریق لینک زیر به پشتیبان اطلاع دهید.</span>
|
||||
</div>
|
||||
<div class="mt-1 text-red-600">در صورت عدم همخوانی کارت ملی و فیش واریزی کیف پول شارژ نمیشود!</div>
|
||||
<div class="mt-4">(سیده زهرا موسوی)</div>
|
||||
<div class="mt-1">شماره کارت:6037701900311389</div>
|
||||
<div>شماره شبا: IR 080160000000001143804581</div>
|
||||
<BasicButton class="mt-4">
|
||||
<a class="w-full" href="https://ble.ir/garoosigallery">پیام به پشتیبانی</a>
|
||||
</BasicButton>
|
||||
</div>
|
||||
<div v-else-if="isBazretalaProject()" class="text-center">
|
||||
<div class="font-bold text-red-600">همراه گرامی</div>
|
||||
<div class="mt-2">
|
||||
<div>جهت شارژ حساب کاربری خود،</div>
|
||||
<div>مبلغ موردنظر را به شماره کارت زیر واریز نموده</div>
|
||||
<div>و به شماره پشتیبانی 09121784258 در برنامه ی بله اطلاع دهید.</div>
|
||||
</div>
|
||||
<div class="mt-4">(گنجينه طلاي حاجي ابوالفتح - بانک ملی)</div>
|
||||
<div class="mt-1">شماره کارت: 6037998800496928</div>
|
||||
<div>شماره شبا: IR 110170000000372468150002</div>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<div class="font-bold text-red-600">مشتریان گرامی</div>
|
||||
<div class="mt-2">
|
||||
<span>{{ supportMessengerText }}</span>
|
||||
</div>
|
||||
<BasicButton v-if="supportMessengerLink" class="mt-3">
|
||||
<a
|
||||
class="w-full"
|
||||
:href="supportMessengerLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
پیام به پشتیبانی
|
||||
</a>
|
||||
</BasicButton>
|
||||
<BasicButton v-if="depositSmsSupportLink" class="mt-2">
|
||||
<a class="w-full" :href="depositSmsSupportLink">
|
||||
پیام به پشتیبانی (sms)
|
||||
</a>
|
||||
</BasicButton>
|
||||
</div>
|
||||
<template #footer>
|
||||
<a-button type="primary" class="w-full" @click="chargeAccountModal = false">
|
||||
متوجه شدم
|
||||
</a-button>
|
||||
<WalletDepositGuideModal
|
||||
v-model:visible="chargeAccountModal"
|
||||
:support-link="depositGuideSupportLink"
|
||||
:sms-link="depositSmsSupportLink"
|
||||
>
|
||||
<template v-if="config.config.name === 'garoosi'">
|
||||
<p>جهت شارژ حساب کاربری خود، مبلغ موردنظر را به شماره کارت زیر واریز نموده و از طریق لینک زیر به پشتیبان اطلاع دهید.</p>
|
||||
<p class="deposit-guide-note deposit-guide-note--warn">در صورت عدم همخوانی کارت ملی و فیش واریزی کیف پول شارژ نمیشود!</p>
|
||||
<p class="deposit-guide-note">(سیده زهرا موسوی)</p>
|
||||
<p class="deposit-guide-note">شماره کارت: 6037701900311389</p>
|
||||
<p class="deposit-guide-note">شماره شبا: IR 080160000000001143804581</p>
|
||||
</template>
|
||||
</BasicModal>
|
||||
|
||||
<template v-else-if="isBazretalaProject()">
|
||||
<p>جهت شارژ حساب کاربری خود، مبلغ موردنظر را به شماره کارت زیر واریز نموده و به شماره پشتیبانی 09121784258 در برنامه بله اطلاع دهید.</p>
|
||||
<p class="deposit-guide-note">(گنجينه طلاي حاجي ابوالفتح - بانک ملی)</p>
|
||||
<p class="deposit-guide-note">شماره کارت: 6037998800496928</p>
|
||||
<p class="deposit-guide-note">شماره شبا: IR 110170000000372468150002</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p>{{ supportMessengerText }}</p>
|
||||
</template>
|
||||
</WalletDepositGuideModal>
|
||||
|
||||
<BasicModal v-model:visible="withdrawInfModal" custom-footer :closable="false">
|
||||
<div class="text-center">
|
||||
@@ -152,6 +124,7 @@ import DesktopWalletView from './desktop/DesktopWalletView.vue'
|
||||
import MobileWalletView from './mobile/MobileWalletView.vue'
|
||||
import SkeletonLoading from './components/SkeletonLoading.vue'
|
||||
import WalletAddCardForm from './components/WalletAddCardForm.vue'
|
||||
import WalletDepositGuideModal from './components/WalletDepositGuideModal.vue'
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
|
||||
@@ -164,7 +137,7 @@ const {
|
||||
infoVisible,
|
||||
refOtpInput,
|
||||
supportMessengerText,
|
||||
supportMessengerLink,
|
||||
depositGuideSupportLink,
|
||||
depositSmsSupportLink,
|
||||
isBazretalaProject,
|
||||
handleOnComplete,
|
||||
@@ -178,4 +151,13 @@ const {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.deposit-guide-note {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.deposit-guide-note--warn {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -358,7 +358,7 @@ import router from '@/router'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useWalletStore } from '@/store/walletStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { setTruth } from '@/main'
|
||||
import { setTruth } from '@/app/authBoot'
|
||||
import {useConfigStore} from "@/store/configStore";
|
||||
import {getConfigApp, getProjectLoginLogo, getProjectLoginTaglineLines, hasCustomLoginLogoLayout} from "@/utils/config";
|
||||
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
|
||||
|
||||
@@ -15,7 +15,7 @@ import router from '@/router'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useWalletStore } from '@/store/walletStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { setTruth } from '@/main'
|
||||
import { setTruth } from '@/app/authBoot'
|
||||
|
||||
export function useLoginForm() {
|
||||
const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss')
|
||||
|
||||
Reference in New Issue
Block a user