Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04dad5cce0 | ||
|
|
f797f5616c | ||
|
|
3a3a55287b | ||
|
|
c3aaeaf91a | ||
|
|
4fa2c0d57d | ||
|
|
8b855e5ee4 | ||
|
|
be17631109 | ||
|
|
2e159dde20 | ||
|
|
548866160c | ||
|
|
f7ba6963ef | ||
|
|
68be4fd5a1 | ||
|
|
511564be64 | ||
|
|
bf34d0fba0 | ||
|
|
53a3e2f61a | ||
|
|
518a5316cb | ||
|
|
400178591e | ||
|
|
80e573ecad |
@@ -7,6 +7,7 @@ export {}
|
|||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||||
AButton: typeof import('ant-design-vue/es')['Button']
|
AButton: typeof import('ant-design-vue/es')['Button']
|
||||||
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
||||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
ADivider: typeof import('ant-design-vue/es')['Divider']
|
||||||
@@ -55,6 +56,7 @@ declare module 'vue' {
|
|||||||
ListAnim: typeof import('./src/components/ListAnim.vue')['default']
|
ListAnim: typeof import('./src/components/ListAnim.vue')['default']
|
||||||
Mode: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/mode.vue')['default']
|
Mode: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/mode.vue')['default']
|
||||||
PresettedRanges: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/presetted-ranges.vue')['default']
|
PresettedRanges: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/presetted-ranges.vue')['default']
|
||||||
|
ProductImage: typeof import('./src/components/ProductImage.vue')['default']
|
||||||
RangePicker: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/range-picker.vue')['default']
|
RangePicker: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/range-picker.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -1,49 +1,696 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.png">
|
<link rel="icon" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
|
||||||
<meta http-equiv="Expires" content="0">
|
|
||||||
<meta name="theme-color" content="#ffffff">
|
|
||||||
<title>Ghatreh</title>
|
|
||||||
|
|
||||||
|
<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>
|
<style>
|
||||||
#whatsapp-button {
|
:root {
|
||||||
position: fixed;
|
--boot-bg: #ffffff;
|
||||||
bottom: 90px;
|
--boot-bg-soft: #f8fafc;
|
||||||
right: 20px;
|
--boot-card: rgba(255, 255, 255, 0.88);
|
||||||
width: 60px;
|
--boot-border: rgba(15, 23, 42, 0.08);
|
||||||
height: 60px;
|
--boot-text: #111827;
|
||||||
background-color: #ffffff;
|
--boot-muted: rgba(15, 23, 42, 0.62);
|
||||||
border-radius: 50%;
|
--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;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
justify-content: center;
|
||||||
cursor: pointer;
|
direction: rtl;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
font-family:
|
||||||
z-index: 9999;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
#whatsapp-button:hover {
|
.boot-loader__card {
|
||||||
transform: scale(1.1);
|
position: relative;
|
||||||
box-shadow: 0 6px 14px rgba(0,0,0,0.3);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
#whatsapp-button:hover + #whatsapp-tooltip {
|
.boot-loader__orb {
|
||||||
opacity: 1;
|
position: absolute;
|
||||||
transform: translateY(0);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
;(function () {
|
||||||
|
var CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
var cookie = document.cookie.split('; ').find(function (row) {
|
||||||
|
return row.indexOf(name + '=') === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!cookie) return null
|
||||||
|
|
||||||
|
return cookie.substring(name.length + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCachedConfig() {
|
||||||
|
try {
|
||||||
|
var rawCookie = getCookie(CONFIG_COOKIE_NAME)
|
||||||
|
|
||||||
|
if (!rawCookie) return null
|
||||||
|
|
||||||
|
var decoded = decodeURIComponent(rawCookie)
|
||||||
|
var payload = JSON.parse(decoded)
|
||||||
|
|
||||||
|
return payload && payload.config ? payload.config : null
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Boot theme: failed to read cached config', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHex(hex) {
|
||||||
|
if (!hex || typeof hex !== 'string') return null
|
||||||
|
|
||||||
|
var value = hex.trim()
|
||||||
|
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
if (value[0] !== '#') {
|
||||||
|
value = '#' + value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^#[0-9a-fA-F]{3}$/.test(value)) {
|
||||||
|
value =
|
||||||
|
'#' +
|
||||||
|
value[1] +
|
||||||
|
value[1] +
|
||||||
|
value[2] +
|
||||||
|
value[2] +
|
||||||
|
value[3] +
|
||||||
|
value[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^#[0-9a-fA-F]{6}$/.test(value)) return null
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
var normalized = normalizeHex(hex)
|
||||||
|
|
||||||
|
if (!normalized) return null
|
||||||
|
|
||||||
|
var num = parseInt(normalized.slice(1), 16)
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: (num >> 16) & 255,
|
||||||
|
g: (num >> 8) & 255,
|
||||||
|
b: num & 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(r, g, b) {
|
||||||
|
function toHex(value) {
|
||||||
|
var hex = Math.max(0, Math.min(255, Math.round(value))).toString(16)
|
||||||
|
return hex.length === 1 ? '0' + hex : hex
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#' + toHex(r) + toHex(g) + toHex(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mix(hexA, hexB, weight) {
|
||||||
|
var a = hexToRgb(hexA)
|
||||||
|
var b = hexToRgb(hexB)
|
||||||
|
|
||||||
|
if (!a || !b) return hexA
|
||||||
|
|
||||||
|
return rgbToHex(
|
||||||
|
a.r * (1 - weight) + b.r * weight,
|
||||||
|
a.g * (1 - weight) + b.g * weight,
|
||||||
|
a.b * (1 - weight) + b.b * weight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function brightness(hex) {
|
||||||
|
var rgb = hexToRgb(hex)
|
||||||
|
|
||||||
|
if (!rgb) return 0
|
||||||
|
|
||||||
|
return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex, alpha) {
|
||||||
|
var rgb = hexToRgb(hex)
|
||||||
|
|
||||||
|
if (!rgb) return 'rgba(96, 165, 250, ' + alpha + ')'
|
||||||
|
|
||||||
|
return 'rgba(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ', ' + alpha + ')'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVar(name, value) {
|
||||||
|
document.documentElement.style.setProperty(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBootTheme() {
|
||||||
|
var config = readCachedConfig()
|
||||||
|
var user = config && config.user ? config.user : null
|
||||||
|
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
var tenantColor = normalizeHex(user.color)
|
||||||
|
var tenantSecondary = normalizeHex(user.secondary_color)
|
||||||
|
var tenantBackground = normalizeHex(user.background_color)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مهم:
|
||||||
|
* background_color بکگراند اپ است.
|
||||||
|
* اگر نبود، لودینگ باید روشن بماند، نه اینکه color را بکگراند کنیم.
|
||||||
|
*/
|
||||||
|
var bg = tenantBackground || '#ffffff'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* color و secondary_color فقط برای accent استفاده میشوند.
|
||||||
|
*/
|
||||||
|
var accent = tenantSecondary || tenantColor || '#2563eb'
|
||||||
|
|
||||||
|
var isLight = brightness(bg) > 155
|
||||||
|
|
||||||
|
var bgSoft = isLight ? mix(bg, accent, 0.045) : mix(bg, '#ffffff', 0.055)
|
||||||
|
|
||||||
|
var text = isLight ? '#111827' : '#ffffff'
|
||||||
|
var muted = isLight ? 'rgba(15, 23, 42, 0.62)' : 'rgba(255, 255, 255, 0.70)'
|
||||||
|
|
||||||
|
var card = isLight ? 'rgba(255, 255, 255, 0.88)' : 'rgba(255, 255, 255, 0.07)'
|
||||||
|
var border = isLight ? 'rgba(15, 23, 42, 0.08)' : 'rgba(255, 255, 255, 0.12)'
|
||||||
|
|
||||||
|
var orb = hexToRgba(accent, isLight ? 0.10 : 0.18)
|
||||||
|
var spinnerTrack = isLight ? 'rgba(15, 23, 42, 0.10)' : 'rgba(255, 255, 255, 0.14)'
|
||||||
|
var shadow = isLight ? 'rgba(15, 23, 42, 0.10)' : 'rgba(0, 0, 0, 0.24)'
|
||||||
|
|
||||||
|
setVar('--boot-bg', bg)
|
||||||
|
setVar('--boot-bg-soft', bgSoft)
|
||||||
|
setVar('--boot-accent', accent)
|
||||||
|
setVar('--boot-text', text)
|
||||||
|
setVar('--boot-muted', muted)
|
||||||
|
setVar('--boot-card', card)
|
||||||
|
setVar('--boot-border', border)
|
||||||
|
setVar('--boot-orb', orb)
|
||||||
|
setVar('--boot-spinner-track', spinnerTrack)
|
||||||
|
setVar('--boot-shadow', shadow)
|
||||||
|
|
||||||
|
document.title = user.name_fa || user.name || 'در حال بارگذاری...'
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBootTheme()
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app">
|
||||||
|
<div class="boot-loader">
|
||||||
|
<div class="boot-loader__card">
|
||||||
|
<div class="boot-loader__orb"></div>
|
||||||
|
<div class="boot-loader__spinner"></div>
|
||||||
|
|
||||||
|
<h1 class="boot-loader__title">در حال هدایت به سامانه</h1>
|
||||||
|
<p class="boot-loader__text">لطفاً چند لحظه صبر کنید...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
;(function () {
|
||||||
|
var BOOT_TIMEOUT_MS = 90000
|
||||||
|
|
||||||
|
var RETRY_KEY = '__APP_BOOT_RETRY_CLICKED__'
|
||||||
|
var RECOVERY_KEY = '__APP_BOOT_RECOVERY_DONE__'
|
||||||
|
|
||||||
|
var CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
|
||||||
|
var CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
|
||||||
|
|
||||||
|
var TOKEN_STORAGE_KEY = 'liman_user_token'
|
||||||
|
var VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
||||||
|
var VERSION_STORAGE_KEY = 'version'
|
||||||
|
var CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||||
|
|
||||||
|
function getProtocol() {
|
||||||
|
return window.location.protocol === 'https:' ? 'https:' : 'http:'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersionUrls() {
|
||||||
|
return ['https://nerkh.gold/get_version', 'https://tablo.talasevin.ir/get_version', 'https://abshode-service.montgit.ir/server/get_version']
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigUrls() {
|
||||||
|
var protocol = getProtocol()
|
||||||
|
|
||||||
|
return [
|
||||||
|
protocol + '//nerkh.gold/get_user',
|
||||||
|
protocol + '//tablo.talasevin.ir/get_user',
|
||||||
|
protocol + '//abshode-service.montgit.ir/server/get_user',
|
||||||
|
protocol + '//212.23.201.173:8381/get_user',
|
||||||
|
protocol + '//91.212.174.137:4040/server/get_user'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortVersionUrls(urls) {
|
||||||
|
var lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
|
||||||
|
|
||||||
|
if (!lastSuccessUrl) {
|
||||||
|
return urls.slice()
|
||||||
|
}
|
||||||
|
|
||||||
|
return [lastSuccessUrl].concat(urls.filter(function (url) {
|
||||||
|
return url !== lastSuccessUrl
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByLastSuccess(urls) {
|
||||||
|
var lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
|
||||||
|
return urls.slice().sort(function (a, b) {
|
||||||
|
if (a === lastSuccessUrl) return -1
|
||||||
|
if (b === lastSuccessUrl) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVersionResponse(data) {
|
||||||
|
if (typeof data === 'string' || typeof data === 'number') {
|
||||||
|
return String(data).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return ''
|
||||||
|
|
||||||
|
if (typeof data.data === 'string' || typeof data.data === 'number') {
|
||||||
|
return String(data.data).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
var version =
|
||||||
|
data.version ||
|
||||||
|
data.value ||
|
||||||
|
(data.data && typeof data.data === 'object' ? data.data.version : '')
|
||||||
|
|
||||||
|
return version ? String(version).trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJsonOrText(url, timeout) {
|
||||||
|
var controller = new AbortController()
|
||||||
|
|
||||||
|
var timeoutId = window.setTimeout(function () {
|
||||||
|
controller.abort()
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Request failed: ' + response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType = response.headers.get('content-type') || ''
|
||||||
|
|
||||||
|
if (contentType.indexOf('application/json') !== -1) {
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text()
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVersionFromUrl(url, timeout) {
|
||||||
|
var controller = new AbortController()
|
||||||
|
var timeoutId = window.setTimeout(function () {
|
||||||
|
controller.abort()
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Version request failed: ' + response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType = response.headers.get('content-type') || ''
|
||||||
|
var data
|
||||||
|
|
||||||
|
if (contentType.indexOf('application/json') !== -1) {
|
||||||
|
data = await response.json()
|
||||||
|
} else {
|
||||||
|
data = await response.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = normalizeVersionResponse(data)
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
throw new Error('Version response is empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
|
||||||
|
return version
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Version failed:', url, error)
|
||||||
|
return undefined
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRemoteVersion(rounds) {
|
||||||
|
var urls = sortVersionUrls(getVersionUrls())
|
||||||
|
|
||||||
|
for (var round = 1; round <= rounds; round++) {
|
||||||
|
for (var i = 0; i < urls.length; i++) {
|
||||||
|
var timeout = i === urls.length - 1 ? 6000 : 3500
|
||||||
|
var result = await fetchVersionFromUrl(urls[i], timeout)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFreshConfig(rounds) {
|
||||||
|
var urls = sortByLastSuccess(getConfigUrls())
|
||||||
|
|
||||||
|
for (var round = 1; round <= rounds; round++) {
|
||||||
|
for (var i = 0; i < urls.length; i++) {
|
||||||
|
var url = urls[i]
|
||||||
|
|
||||||
|
try {
|
||||||
|
var config = await fetchJsonOrText(url, i === urls.length - 1 ? 7000 : 4000)
|
||||||
|
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
throw new Error('Config response is invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: config,
|
||||||
|
url: url
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Config failed:', url, 'round:', round, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Cannot fetch fresh config')
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfigCookie(config) {
|
||||||
|
try {
|
||||||
|
var payload = {
|
||||||
|
cached_at: Date.now(),
|
||||||
|
config: config
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = encodeURIComponent(JSON.stringify(payload))
|
||||||
|
var secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||||
|
|
||||||
|
document.cookie =
|
||||||
|
CONFIG_COOKIE_NAME +
|
||||||
|
'=' +
|
||||||
|
encoded +
|
||||||
|
'; Path=/; Max-Age=' +
|
||||||
|
CONFIG_COOKIE_MAX_AGE_SECONDS +
|
||||||
|
'; SameSite=Lax' +
|
||||||
|
secure
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to write config cookie:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReadableCookies() {
|
||||||
|
document.cookie.split(';').forEach(function (cookie) {
|
||||||
|
var name = cookie.split('=')[0] && cookie.split('=')[0].trim()
|
||||||
|
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
document.cookie = name + '=; Path=/; Max-Age=0; SameSite=Lax'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalStorageExceptToken() {
|
||||||
|
var token = localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||||
|
|
||||||
|
localStorage.clear()
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearServiceWorkersAndCaches() {
|
||||||
|
try {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
var registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
registrations.map(function (registration) {
|
||||||
|
return registration.unregister()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to unregister service workers:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ('caches' in window) {
|
||||||
|
var keys = await caches.keys()
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
keys.map(function (key) {
|
||||||
|
return caches.delete(key)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to clear caches:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadWithCacheBust() {
|
||||||
|
var url = new URL(window.location.href)
|
||||||
|
url.searchParams.set('__boot_recover', String(Date.now()))
|
||||||
|
window.location.replace(url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecovering(text) {
|
||||||
|
var app = document.getElementById('app')
|
||||||
|
if (!app) return
|
||||||
|
|
||||||
|
app.innerHTML =
|
||||||
|
'<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg); color: var(--boot-text);">' +
|
||||||
|
'<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card); border: 1px solid var(--boot-border); border-radius: 24px; padding: 28px 22px;">' +
|
||||||
|
'<div style="width: 54px; height: 54px; margin: 0 auto 18px; border-radius: 999px; border: 4px solid rgba(255,255,255,0.18); border-top-color: var(--boot-accent); animation: bootSpin 0.9s linear infinite;"></div>' +
|
||||||
|
'<style>@keyframes bootSpin { to { transform: rotate(360deg); } }</style>' +
|
||||||
|
'<h2 style="margin: 0 0 12px; font-size: 20px;">در حال بازیابی برنامه</h2>' +
|
||||||
|
'<p style="margin: 0; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
|
||||||
|
(text || 'لطفاً چند لحظه صبر کنید.') +
|
||||||
|
'</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBootError() {
|
||||||
|
var app = document.getElementById('app')
|
||||||
|
if (!app) return
|
||||||
|
|
||||||
|
app.innerHTML =
|
||||||
|
'<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg); color: var(--boot-text);">' +
|
||||||
|
'<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card); border: 1px solid var(--boot-border); border-radius: 24px; padding: 28px 22px;">' +
|
||||||
|
'<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>' +
|
||||||
|
'<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در بارگذاری برنامه</h2>' +
|
||||||
|
'<p style="margin: 0 0 20px; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
|
||||||
|
'ارتباط با سرور برقرار نشد یا فایلهای برنامه بهدرستی دریافت نشدند.' +
|
||||||
|
'</p>' +
|
||||||
|
'<button onclick="window.__APP_BOOT_SIMPLE_RETRY__()" style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent); color: white; cursor: pointer; font-weight: 700;">' +
|
||||||
|
'تلاش مجدد' +
|
||||||
|
'</button>' +
|
||||||
|
'<button onclick="window.__APP_BOOT_CLEAN_RELOAD__()" style="margin-right: 8px; padding: 11px 18px; border: 1px solid var(--boot-border); border-radius: 12px; background: transparent; color: var(--boot-text); cursor: pointer; font-weight: 700;">' +
|
||||||
|
'پاکسازی و تلاش مجدد' +
|
||||||
|
'</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanFetchAndReload() {
|
||||||
|
renderRecovering('در حال دریافت تنظیمات جدید برنامه...')
|
||||||
|
|
||||||
|
var version = await loadRemoteVersion(5)
|
||||||
|
var freshConfigResult = await fetchFreshConfig(2)
|
||||||
|
|
||||||
|
clearReadableCookies()
|
||||||
|
// clearLocalStorageExceptToken()
|
||||||
|
await clearServiceWorkersAndCaches()
|
||||||
|
|
||||||
|
writeConfigCookie(freshConfigResult.config)
|
||||||
|
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, freshConfigResult.url)
|
||||||
|
|
||||||
|
if (version) {
|
||||||
|
localStorage.setItem(VERSION_STORAGE_KEY, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.removeItem(RETRY_KEY)
|
||||||
|
sessionStorage.removeItem(RECOVERY_KEY)
|
||||||
|
|
||||||
|
reloadWithCacheBust()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__APP_BOOT_SIMPLE_RETRY__ = function () {
|
||||||
|
sessionStorage.setItem(RETRY_KEY, '1')
|
||||||
|
reloadWithCacheBust()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__APP_BOOT_CLEAN_RELOAD__ = async function () {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(RECOVERY_KEY, '1')
|
||||||
|
await cleanFetchAndReload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Manual boot recovery failed:', error)
|
||||||
|
renderBootError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__BOOT_TIMEOUT__ = window.setTimeout(async function () {
|
||||||
|
if (window.__APP_BOOTSTRAPPED__) return
|
||||||
|
|
||||||
|
var app = document.getElementById('app')
|
||||||
|
var stillBooting = app && app.querySelector('.boot-loader')
|
||||||
|
|
||||||
|
if (!stillBooting) return
|
||||||
|
|
||||||
|
if (!sessionStorage.getItem(RETRY_KEY)) {
|
||||||
|
renderBootError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionStorage.getItem(RECOVERY_KEY)) {
|
||||||
|
sessionStorage.setItem(RECOVERY_KEY, '1')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cleanFetchAndReload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto boot recovery after retry failed:', error)
|
||||||
|
renderBootError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBootError()
|
||||||
|
}, BOOT_TIMEOUT_MS)
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<a href="https://web.rubika.ir/#c=u0I8F1U0a939b0df5ba9f73618d33d7c" target="_blank" id="whatsapp-button">
|
|
||||||
<img class="p-2" src="/src/assets/img/rubika.webp" alt="" />
|
|
||||||
</a>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"dayjalali": "^1.0.1",
|
"dayjalali": "^1.0.1",
|
||||||
"echarts": "^5.4.3",
|
"echarts": "^5.4.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
"jalaliday": "^2.3.0",
|
"jalaliday": "^2.3.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
|
||||||
"target": {
|
|
||||||
"namespace": "android_app",
|
|
||||||
"package_name": "ir.ghatreh.app",
|
|
||||||
"sha256_cert_fingerprints": ["60:54:C6:C1:22:6C:2E:EA:A1:05:02:B1:17:F5:7D:49:1A:C0:84:FF:4C:ED:FD:42:D9:7F:DF:B2:70:37:8E:03"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 450 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 583 KiB |
@@ -1,96 +1,108 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="desktopConfig w-full right-0 left-0 absolute">
|
<div class="desktopConfig w-full right-0 left-0">
|
||||||
<div v-if="popVisible" class="top-0 bottom-0 wrap-fix bg-primary z-100 opacity-60">
|
|
||||||
<div class="absolute right-0 left-0 bottom-10vh flex flex-col items-center">
|
|
||||||
<div><BasicButton>درباره ما</BasicButton></div>
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<transition mode="out-in" name="fade">
|
<transition mode="out-in" name="fade">
|
||||||
<AConfigProvider
|
<AConfigProvider
|
||||||
v-if="!showSplash"
|
v-if="!showSplash"
|
||||||
:theme="{
|
:theme="{
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: colors.primary,
|
colorPrimary: primaryColor,
|
||||||
colorLink: colors.primary,
|
colorLink: primaryColor,
|
||||||
colorLinkActive: colors.primary,
|
colorLinkActive: primaryColor,
|
||||||
colorLinkHover: colors['primary-rgba']
|
colorLinkHover: primaryColor
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
direction="rtl"
|
direction="rtl"
|
||||||
>
|
>
|
||||||
<router-view v-slot="{ Component, route }">
|
<router-view v-slot="{ Component, route }">
|
||||||
<!-- Use any custom transition and to `fade` -->
|
|
||||||
<transition :name="route.meta.transition || 'fade'">
|
<transition :name="route.meta.transition || 'fade'">
|
||||||
<div>
|
<!--
|
||||||
<component :is="Component" />
|
Key only the top-level matched route (e.g. main vs login), not fullPath.
|
||||||
</div>
|
Using fullPath remounted the whole AppLayoutShell (sidebar/bottom nav)
|
||||||
|
on every page change and reloaded the menu logo each time.
|
||||||
|
-->
|
||||||
|
<component
|
||||||
|
:is="Component"
|
||||||
|
:key="route.matched[0]?.name ?? route.name"
|
||||||
|
class="app-main"
|
||||||
|
/>
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
</AConfigProvider>
|
</AConfigProvider>
|
||||||
|
|
||||||
<div v-else class="flex bottom-0 wrap-fix top-0 bg-white z-1000000000000 justify-center">
|
<div v-else class="app-splash-screen flex items-center justify-center bg-white">
|
||||||
<div class="top-0 wrap-fix h-1/3 w-screen flex">
|
<div class="app-splash-screen__gradient app-splash-screen__gradient--top h-1/3 flex">
|
||||||
<img :src="img_topGradiant" alt="" class="absolute z-10 w-screen h-full object-cover" />
|
<img :src="img_topGradiant" alt="" class="absolute z-10 w-full h-full object-cover" />
|
||||||
<img :src="img_pattern" alt="" class="absolute w-screen h-full object-none" />
|
<img :src="img_pattern" alt="" class="absolute w-full h-full object-none" />
|
||||||
</div>
|
</div>
|
||||||
<div class="wrap-fix bottom-0 h-1/3 w-screen">
|
<div class="app-splash-screen__gradient app-splash-screen__gradient--bottom h-1/3">
|
||||||
<img
|
<img
|
||||||
:src="img_bottomGradiant"
|
:src="img_bottomGradiant"
|
||||||
alt=""
|
alt=""
|
||||||
class="absolute z-10 w-screen h-full object-cover"
|
class="absolute z-10 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<img :src="img_pattern" alt="" class="absolute w-screen h-full object-none" />
|
<img :src="img_pattern" alt="" class="absolute w-full h-full object-none" />
|
||||||
</div>
|
</div>
|
||||||
<div class="self-center splash_appear_fade_in">
|
<div class="splash_appear_fade_in relative z-10">
|
||||||
<img :src="logoFull" class="h-[20vh]">
|
<img :src="logoFull" class="h-[20vh] max-h-[180px] w-auto object-contain" alt="" />
|
||||||
<!-- <img :src="logo" alt="" class="z-100 bg-primary rounded-full w-full aspect-square self-center h-40" />-->
|
|
||||||
</div>
|
</div>
|
||||||
<div class="fixed w-full bottom-5 home_appear_fade_in_up">
|
<div class="app-splash-screen__version home_appear_fade_in_up">
|
||||||
<div class="text-center text-xs">نسخه: {{ pkg.version }}</div>
|
<div class="text-center text-xs">نسخه: {{ displayVersion }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-10">
|
<div class="app-splash-screen__spinner">
|
||||||
<a-spin :indicator="indicator" size="small" />
|
<a-spin :indicator="indicator" size="small" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
<van-dialog
|
|
||||||
v-model:show="versionDialog"
|
<div
|
||||||
class="updateButton"
|
v-if="showSupportButton && !showSplash"
|
||||||
confirm-button-text="به روزرسانی کنید"
|
id="support-button"
|
||||||
show-confirm-button
|
class="draggable-support-button"
|
||||||
@confirm="update()"
|
:class="{ 'is-dragging': supportDragging }"
|
||||||
|
:style="supportButtonStyle"
|
||||||
|
title="پشتیبانی"
|
||||||
|
@pointerdown="onSupportPointerDown"
|
||||||
|
@pointermove="onSupportPointerMove"
|
||||||
|
@pointerup="handleSupportPointerUp"
|
||||||
|
@pointercancel="onSupportPointerCancel"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center p-2">
|
<div class="support-button-inner" :style="{ '--support-accent': supportAccentColor }">
|
||||||
<DotLottieVue
|
<BasicIcon :icon="supportIcon" size="18" class="support-button-icon" />
|
||||||
class="w-full aspect-square"
|
<span class="support-button-label">پشتیبانی</span>
|
||||||
autoplay
|
|
||||||
loop
|
|
||||||
src="https://lottie.host/cb620e8e-c64a-42e8-a6b5-8844146e5999/nmft7oBO8q.json"
|
|
||||||
/>
|
|
||||||
<div class="home_appear_fade_in_up mt-2">نسخه جدید قطره منتشر شد!</div>
|
|
||||||
</div>
|
</div>
|
||||||
</van-dialog>
|
</div>
|
||||||
|
|
||||||
|
<VersionUpdateModal />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { colors } from './themeConfig'
|
import { colors } from './themeConfig'
|
||||||
import pkg from '../package.json'
|
import pkg from '../package.json'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
import { getDisplayAppVersion } from '@/config/config-cache'
|
||||||
import { h, onMounted, ref } from 'vue'
|
import { getAppPrimaryColor } from '@/config/brand-theme'
|
||||||
import { update } from 'lodash'
|
import { computed, defineAsyncComponent, h, onMounted, ref } from 'vue'
|
||||||
import img_topGradiant from '@/views/login/assets/img/gr_top_white.svg'
|
import img_topGradiant from '@/views/login/assets/img/gr_top_white.svg'
|
||||||
import img_pattern from '@/views/login/assets/img/pattern.svg'
|
import img_pattern from '@/views/login/assets/img/pattern.svg'
|
||||||
import { LoadingOutlined } from '@ant-design/icons-vue'
|
import { LoadingOutlined } from '@ant-design/icons-vue'
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import {
|
||||||
|
buildSupportMessengerUrl,
|
||||||
|
loadSupportMessengerFromSettings,
|
||||||
|
supportMessengerSettings,
|
||||||
|
} from '@/utils/supportMessenger'
|
||||||
|
import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton'
|
||||||
|
import { useApplyDesktopLayoutClass } from '@/layouts/composables/useAppLayout'
|
||||||
|
|
||||||
|
import { getAppLogoFullUrl } from '@/config/brand-assets'
|
||||||
|
|
||||||
import img_bottomGradiant from '@/views/login/assets/img/gr_bottom_white.svg'
|
import img_bottomGradiant from '@/views/login/assets/img/gr_bottom_white.svg'
|
||||||
import logoFull from '@/assets/img/logo_full_.png'
|
|
||||||
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
|
|
||||||
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
|
||||||
|
|
||||||
const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW()
|
const logoFull = getAppLogoFullUrl()
|
||||||
|
|
||||||
|
const VersionUpdateModal = defineAsyncComponent(
|
||||||
|
() => import('@/modules/version/VersionUpdateModal.vue')
|
||||||
|
)
|
||||||
|
|
||||||
const indicator = h(LoadingOutlined, {
|
const indicator = h(LoadingOutlined, {
|
||||||
style: {
|
style: {
|
||||||
@@ -98,48 +110,140 @@ const indicator = h(LoadingOutlined, {
|
|||||||
},
|
},
|
||||||
spin: true
|
spin: true
|
||||||
})
|
})
|
||||||
|
const displayVersion = getDisplayAppVersion(pkg.version)
|
||||||
const showSplash = ref(true)
|
const showSplash = ref(true)
|
||||||
|
const primaryColor = getAppPrimaryColor(colors.primary || '#000000')
|
||||||
|
|
||||||
|
useApplyDesktopLayoutClass()
|
||||||
|
|
||||||
onMounted(() => {
|
const supportLink = computed(() =>
|
||||||
checkVersion()
|
buildSupportMessengerUrl(
|
||||||
|
supportMessengerSettings.value.messenger,
|
||||||
|
supportMessengerSettings.value.messengerId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const showSupportButton = computed(() => !!supportLink.value)
|
||||||
|
|
||||||
|
const supportIcon = computed(() => {
|
||||||
|
const messenger = (supportMessengerSettings.value.messenger ?? '').toLowerCase()
|
||||||
|
switch (messenger) {
|
||||||
|
case 'whatsapp':
|
||||||
|
return 'vuesax-linear:whatsapp'
|
||||||
|
case 'telegram':
|
||||||
|
return 'vuesax-linear:send-2'
|
||||||
|
case 'bale':
|
||||||
|
return 'vuesax-linear:messages-2'
|
||||||
|
default:
|
||||||
|
return 'vuesax-linear:messages-2'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const supportAccentColor = computed(() => {
|
||||||
|
const messenger = (supportMessengerSettings.value.messenger ?? '').toLowerCase()
|
||||||
|
switch (messenger) {
|
||||||
|
case 'whatsapp':
|
||||||
|
return '#22c55e'
|
||||||
|
case 'telegram':
|
||||||
|
return '#0ea5e9'
|
||||||
|
case 'bale':
|
||||||
|
return '#16a34a'
|
||||||
|
case 'rubika':
|
||||||
|
return '#7c3aed'
|
||||||
|
case 'eitaa':
|
||||||
|
return '#f59e0b'
|
||||||
|
case 'soroush':
|
||||||
|
return '#2563eb'
|
||||||
|
default:
|
||||||
|
return primaryColor || '#334155'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
dragging: supportDragging,
|
||||||
|
fabStyle: supportButtonStyle,
|
||||||
|
handlePointerDown: onSupportPointerDown,
|
||||||
|
handlePointerMove: onSupportPointerMove,
|
||||||
|
handlePointerUp: supportPointerUp,
|
||||||
|
handlePointerCancel: onSupportPointerCancel,
|
||||||
|
} = useDraggableFloatingButton('ghatreh_support_button_position_v1', {
|
||||||
|
width: 118,
|
||||||
|
height: 42,
|
||||||
|
defaultBottomGap: 12,
|
||||||
|
defaultRightOffset: 16,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openSupportLink() {
|
||||||
|
if (!supportLink.value) return
|
||||||
|
window.open(supportLink.value, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSupportPointerUp(event: PointerEvent) {
|
||||||
|
if (supportPointerUp(event)) {
|
||||||
|
openSupportLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadSupportMessengerFromSettings()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showSplash.value = false
|
showSplash.value = false
|
||||||
}, 2000)
|
}, 2000)
|
||||||
})
|
})
|
||||||
|
|
||||||
const versionDialog = ref<boolean>(false)
|
|
||||||
|
|
||||||
function checkVersion() {
|
|
||||||
const version = localStorage.getItem('version')
|
|
||||||
// if (true) {
|
|
||||||
if (!version){
|
|
||||||
update()
|
|
||||||
}
|
|
||||||
if (version != pkg.version) {
|
|
||||||
versionDialog.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function update() {
|
|
||||||
localStorage.setItem('version', pkg.version)
|
|
||||||
updateServiceWorker()
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less">
|
<style lang="less">
|
||||||
.updateButton .van-dialog__confirm .van-button__text {
|
.updateButton .van-dialog__confirm .van-button__text {
|
||||||
color: @primary-color !important;
|
color: var(--app-primary-color, #a72b77) !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 426px) {
|
#support-button {
|
||||||
.desktopConfig {
|
position: fixed;
|
||||||
width: 426px;
|
z-index: 5100;
|
||||||
position: relative !important;
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
|
||||||
|
&.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.support-button-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 14px 9px 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.14);
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
direction: rtl;
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
|
|
||||||
|
.support-button-icon {
|
||||||
|
color: var(--support-accent, #334155);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-button-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#support-button:not(.is-dragging):hover .support-button-inner {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
#support-button.is-dragging .support-button-inner {
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import useAxios from '@/utils/useAxios'
|
||||||
|
|
||||||
|
export interface StoreCategory {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
sort_order?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStoreCategories = async (): Promise<StoreCategory[]> =>
|
||||||
|
await useAxios.get<StoreCategory[]>('/customer/categories')
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import useAxios from '@/utils/useAxios'
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
return await useAxios.get(`api/get_settings`)
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ import type { PWallet, Wallet } from '@/models/wallet'
|
|||||||
|
|
||||||
|
|
||||||
export const getProducts = async (): Promise<any> => {
|
export const getProducts = async (): Promise<any> => {
|
||||||
return await useAxios.get<Product[]>(`/customer/product/list`)
|
return await useAxios.get<Product[]>(`/customer/product/list`, { timeout: 15000 })
|
||||||
}
|
}
|
||||||
export const getProductById = async ( id:number|null): Promise<any> => {
|
export const getProductById = async ( id:number|null): Promise<any> => {
|
||||||
return await useAxios.get<Product[]>(`/customer/product/list/${id}`)
|
return await useAxios.get<Product[]>(`/customer/product/list/${id}`, { timeout: 15000 })
|
||||||
}
|
}
|
||||||
export const getProductInventory = async (): Promise<any> => {
|
export const getProductInventory = async (): Promise<any> => {
|
||||||
return await useAxios.post<PInventory[]>(`/customer/wallet/product/table`)
|
return await useAxios.post<PInventory[]>(`/customer/wallet/product/table`)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import useAxios from '@/utils/useAxios'
|
import useAxios from '@/utils/useAxios'
|
||||||
import type { Card, Wallet } from '@/models/wallet'
|
import type { Card, Wallet } from '@/models/wallet'
|
||||||
|
import type { WalletAssetsSummary } from '@/models/walletAssets'
|
||||||
import type {Transaction} from '@/models/transaction'
|
import type {Transaction} from '@/models/transaction'
|
||||||
|
|
||||||
export const getWallet = async () => await useAxios.get<Wallet>(`/customer/wallet/me`)
|
export const getWallet = async () => await useAxios.get<Wallet>(`/customer/wallet/me`)
|
||||||
|
|
||||||
|
export const getWalletAssets = async () =>
|
||||||
|
await useAxios.get<WalletAssetsSummary>(`/customer/wallet/assets`)
|
||||||
|
|
||||||
export const withdraw = async (obj: Transaction): Promise<any> => {
|
export const withdraw = async (obj: Transaction): Promise<any> => {
|
||||||
return await useAxios.post<Transaction>(`/customer/wallet/withdraw`, obj)
|
return await useAxios.post<Transaction>(`/customer/wallet/withdraw`, obj)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { RouteLocationNormalized } from 'vue-router'
|
||||||
|
import router from '@/router'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
|
||||||
|
let fingerPosition = false
|
||||||
|
let fingerTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let hasFingerPrint = false
|
||||||
|
|
||||||
|
export function setTruth() {
|
||||||
|
fingerPosition = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setEnableAuthenticate() {
|
||||||
|
fingerPosition = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFingerPosition(value: boolean) {
|
||||||
|
fingerPosition = value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFingerPosition() {
|
||||||
|
return fingerPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHasFingerPrint(value: boolean) {
|
||||||
|
hasFingerPrint = value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHasFingerPrint() {
|
||||||
|
return hasFingerPrint
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFingerTimeout() {
|
||||||
|
if (fingerTimeoutId) {
|
||||||
|
clearTimeout(fingerTimeoutId)
|
||||||
|
fingerTimeoutId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFingerTimeOut(to: RouteLocationNormalized) {
|
||||||
|
clearFingerTimeout()
|
||||||
|
|
||||||
|
fingerTimeoutId = setTimeout(async () => {
|
||||||
|
if (!hasFingerPrint) return
|
||||||
|
|
||||||
|
fingerPosition = false
|
||||||
|
await router.push({ name: 'login' })
|
||||||
|
|
||||||
|
if (to.name !== 'login') {
|
||||||
|
message.warn('شما مدت زیادی با کشکول گلد کار نکردید، لطفا دوباره وارد شوید.')
|
||||||
|
}
|
||||||
|
}, 180000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { loadAppConfig } from '@/config/load-config'
|
||||||
|
import { setAppConfig } from '@/config/app-config'
|
||||||
|
import { applyAppConfig } from '@/config/apply-config'
|
||||||
|
import { startPwaManifest } from '@/modules/pwa/manifest'
|
||||||
|
import { createVueApp } from '@/app/createApp'
|
||||||
|
import { setupRouterGuards } from '@/app/setupRouter'
|
||||||
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
|
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
|
||||||
|
import { initMobileScrollRestoration } from '@/layouts/composables/useMobileScrollReset'
|
||||||
|
|
||||||
|
type BootWindow = Window & {
|
||||||
|
__BOOT_TIMEOUT__?: number
|
||||||
|
__APP_BOOTSTRAPPED__?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBootTimeout() {
|
||||||
|
const bootWindow = window as BootWindow
|
||||||
|
|
||||||
|
if (bootWindow.__BOOT_TIMEOUT__) {
|
||||||
|
window.clearTimeout(bootWindow.__BOOT_TIMEOUT__)
|
||||||
|
bootWindow.__BOOT_TIMEOUT__ = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
reject(new Error(message))
|
||||||
|
}, ms)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBootstrapError(error: unknown) {
|
||||||
|
clearBootTimeout()
|
||||||
|
|
||||||
|
console.error('Bootstrap failed:', error)
|
||||||
|
|
||||||
|
const appRoot = document.querySelector('#app')
|
||||||
|
|
||||||
|
if (!appRoot) return
|
||||||
|
|
||||||
|
appRoot.innerHTML = `
|
||||||
|
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; direction: rtl; font-family: sans-serif; padding: 24px; background: var(--boot-bg, #ffffff); color: var(--boot-text, #111827);">
|
||||||
|
<div style="width: min(420px, 100%); text-align: center; background: var(--boot-card, #fff); border: 1px solid var(--boot-border, rgba(15,23,42,0.08)); border-radius: 24px; padding: 28px 22px;">
|
||||||
|
<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>
|
||||||
|
<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در راهاندازی برنامه</h2>
|
||||||
|
<p style="margin: 0 0 20px; color: var(--boot-muted, rgba(15,23,42,0.62)); line-height: 2; font-size: 14px;">
|
||||||
|
لطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick="location.reload()"
|
||||||
|
style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent, #2563eb); color: white; cursor: pointer; font-weight: 700;"
|
||||||
|
>
|
||||||
|
تلاش مجدد
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapApp() {
|
||||||
|
try {
|
||||||
|
const config = await withTimeout(loadAppConfig(), 20000, 'Config loading timeout')
|
||||||
|
|
||||||
|
setAppConfig(config)
|
||||||
|
startPwaManifest(config)
|
||||||
|
applyAppConfig()
|
||||||
|
|
||||||
|
const { app } = createVueApp()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
await withTimeout(settingsStore.fetchSettings(), 45000, 'Settings loading timeout')
|
||||||
|
initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key))
|
||||||
|
|
||||||
|
initMobileScrollRestoration()
|
||||||
|
setupRouterGuards()
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
|
||||||
|
;(window as BootWindow).__APP_BOOTSTRAPPED__ = true
|
||||||
|
clearBootTimeout()
|
||||||
|
sessionStorage.removeItem('__APP_BOOT_RECOVERY_DONE__')
|
||||||
|
sessionStorage.removeItem('__APP_BOOT_RETRY_CLICKED__')
|
||||||
|
} catch (error) {
|
||||||
|
showBootstrapError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,179 @@
|
|||||||
|
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 type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/store/useAuthStore'
|
||||||
|
import {
|
||||||
|
clearFingerTimeout,
|
||||||
|
getFingerPosition,
|
||||||
|
getHasFingerPrint,
|
||||||
|
setFingerPosition,
|
||||||
|
setFingerTimeOut,
|
||||||
|
setHasFingerPrint
|
||||||
|
} from '@/app/authBoot'
|
||||||
|
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
|
||||||
|
import {
|
||||||
|
getDefaultLandingRoute,
|
||||||
|
hasAbshodeTrading,
|
||||||
|
hasHomePage,
|
||||||
|
hasProductShop,
|
||||||
|
} from '@/config/project-profile'
|
||||||
|
|
||||||
|
const GUEST_PUBLIC_ROUTES = ['login', '404', 'about', 'cart'] as const
|
||||||
|
|
||||||
|
function getGuestPublicRoutes(): string[] {
|
||||||
|
const routes: string[] = [...GUEST_PUBLIC_ROUTES]
|
||||||
|
if (hasProductShop()) {
|
||||||
|
routes.push('buyProduct', 'productDetail')
|
||||||
|
}
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceProjectRouteAccess(
|
||||||
|
to: RouteLocationNormalized,
|
||||||
|
next: NavigationGuardNext
|
||||||
|
): boolean {
|
||||||
|
if (!hasHomePage() && to.name === 'home') {
|
||||||
|
next({ name: getDefaultLandingRoute() })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAbshodeTrading() && to.name === 'gold') {
|
||||||
|
next({ name: getDefaultLandingRoute() })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasProductShop() && (to.name === 'buyProduct' || to.name === 'productDetail')) {
|
||||||
|
next({ name: getDefaultLandingRoute() })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupRouterGuards() {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const walletStore = useWalletStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const isApple: boolean = isIphone()
|
||||||
|
let isFirst = true
|
||||||
|
|
||||||
|
const currentRoute = computed(() => router.currentRoute.value.fullPath)
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (!getToken()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: Promise<unknown>[] = [
|
||||||
|
walletStore.getLimitedPrice(),
|
||||||
|
walletStore.getLimitedBuyGold(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!userStore.userInfo) {
|
||||||
|
tasks.push(userStore.getMyInfo())
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) {
|
||||||
|
if (getGuestPublicRoutes().includes(to.name as string)) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
if (to.name !== 'login') {
|
||||||
|
return next({ name: 'login' })
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToLanding(to: RouteLocationNormalized, next: NavigationGuardNext) {
|
||||||
|
const landing = getDefaultLandingRoute()
|
||||||
|
if (to.name !== landing) {
|
||||||
|
return next({ name: landing })
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
authStore.setLoading(true)
|
||||||
|
clearFingerTimeout()
|
||||||
|
setFingerTimeOut(to)
|
||||||
|
|
||||||
|
if (enforceProjectRouteAccess(to, next)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFirst) {
|
||||||
|
isFirst = false
|
||||||
|
|
||||||
|
if (getToken()) {
|
||||||
|
await userStore.getMyInfo()
|
||||||
|
const finger_print = userStore.userInfo.finger_print
|
||||||
|
setHasFingerPrint(finger_print)
|
||||||
|
|
||||||
|
if (isApple && finger_print) {
|
||||||
|
removeToken()
|
||||||
|
goToLogin(to, next)
|
||||||
|
} else if (!isApple && finger_print) {
|
||||||
|
setFingerPosition(await authenticateWithFingerprint())
|
||||||
|
|
||||||
|
if (!getFingerPosition()) {
|
||||||
|
goToLogin(to, next)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (to.name == 'login') {
|
||||||
|
return goToLanding(to, next)
|
||||||
|
}
|
||||||
|
if (enforceProjectRouteAccess(to, next)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (getGuestPublicRoutes().includes(to.name as string)) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
goToLogin(to, next)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (getFingerPosition()) {
|
||||||
|
if (getToken()) {
|
||||||
|
if (to.name === 'login') {
|
||||||
|
return next({ name: (from.name as string) || getDefaultLandingRoute() })
|
||||||
|
}
|
||||||
|
if (enforceProjectRouteAccess(to, next)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getGuestPublicRoutes().includes(to.name as string)) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
return next({ name: 'login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getToken() && !getHasFingerPrint()) {
|
||||||
|
if (enforceProjectRouteAccess(to, next)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
} else {
|
||||||
|
goToLogin(to, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.afterEach((to, from) => {
|
||||||
|
if (document.documentElement.classList.contains('app-layout--desktop')) return
|
||||||
|
if (from.name === 'login') {
|
||||||
|
recoverMobileViewportAfterAuth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(currentRoute, async () => (userStore.userInfo ? '' : await start()))
|
||||||
|
}
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
@import (less) './var.less';
|
@import (less) './var.less';
|
||||||
@import (less) './fonts.less';
|
@import (less) './fonts.less';
|
||||||
|
@import (less) '../../layouts/styles/desktop-shell.less';
|
||||||
|
@import (less) '../../layouts/styles/mobile-frame.less';
|
||||||
|
@import (less) '../../layouts/styles/mobile-page-common.less';
|
||||||
|
@import (less) '../../layouts/desktop/styles/desktop-page-common.less';
|
||||||
|
@import (less) '../../views/base/fund/desktop/styles/desktop-fund.less';
|
||||||
|
@import (less) '../../views/base/transactions/desktop/styles/desktop-transactions.less';
|
||||||
|
@import (less) '../../views/base/wallet/desktop/styles/desktop-wallet.less';
|
||||||
|
@import (less) '../../views/base/user/desktop/styles/desktop-user.less';
|
||||||
|
@import (less) '../../views/base/priceTrigger/desktop/styles/desktop-price-trigger.less';
|
||||||
|
@import (less) '../../views/base/home/desktop/styles/desktop-home.less';
|
||||||
|
@import (less) '../../layouts/desktop/styles/desktop-inner-pages.less';
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
@@ -50,6 +61,27 @@ body {
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: only light;
|
color-scheme: only light;
|
||||||
|
--app-primary-color: #a72b77;
|
||||||
|
--app-secondary-color: #a72b77;
|
||||||
|
--app-tertiary-color: #e0be7f;
|
||||||
|
--app-background-color: #ffffff;
|
||||||
|
--app-primary-dark: #711d4d;
|
||||||
|
--app-primary-light: #c45f9a;
|
||||||
|
--app-safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--app-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--app-credit-bar-inner: 0px;
|
||||||
|
--app-home-header-offset: calc(4.25rem + var(--app-safe-top));
|
||||||
|
--app-credit-footer-offset: var(--app-safe-bottom);
|
||||||
|
--app-nav-height: 80px;
|
||||||
|
--app-bottom-chrome-height: calc(var(--app-nav-height) + var(--app-safe-bottom));
|
||||||
|
--app-add-card-height: 88px;
|
||||||
|
--wallet-card-dock-overlap: 52px;
|
||||||
|
--app-z-nav: 5000;
|
||||||
|
--app-z-wallet-dock: 4490;
|
||||||
|
--app-z-wallet-add-bar: 4510;
|
||||||
|
--app-z-action-sheet: 5200;
|
||||||
|
--app-z-dialog: 5300;
|
||||||
|
--app-z-modal: 5500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-form-item {
|
.ant-form-item {
|
||||||
@@ -74,18 +106,23 @@ body {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*.van-action-sheet{
|
.van-action-sheet{
|
||||||
z-index: 2001 !important;
|
z-index: 5001 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.van-overlay{
|
//.van-overlay{
|
||||||
z-index: 2000 !important;
|
// z-index: 2000 !important;
|
||||||
}*/
|
//}
|
||||||
|
|
||||||
.color-transition {
|
.color-transition {
|
||||||
transition: color 0.3s ease;
|
transition: color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
.background-color-transition {
|
.background-color-transition {
|
||||||
transition: background-color 0.3s ease;
|
transition: background-color 0.3s ease;
|
||||||
}
|
}
|
||||||
@@ -133,7 +170,7 @@ body {
|
|||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
//display: none;
|
//display: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
border: 1px solid #521620 !important;
|
border: 1px solid var(--app-primary-color, #a72b77) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,40 +368,49 @@ body {
|
|||||||
}
|
}
|
||||||
.ant-radio-group-solid
|
.ant-radio-group-solid
|
||||||
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover{
|
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover{
|
||||||
color: #521620 !important;
|
color: var(--app-primary-color, #a72b77) !important;
|
||||||
}
|
}
|
||||||
.ant-input-group-addon{
|
.ant-input-group-addon{
|
||||||
background-color: #521620 !important;
|
background-color: var(--app-primary-color, #a72b77) !important;
|
||||||
color: #e0be7f !important;
|
color: var(--app-tertiary-color, #e0be7f) !important;
|
||||||
}
|
}
|
||||||
.ant-slider-track{
|
.ant-slider-track{
|
||||||
background-color: #521620 !important;
|
background-color: var(--app-primary-color, #a72b77) !important;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
.ant-radio-checked .ant-radio-inner,.ant-radio-group-solid .ant-radio-button-wrapper-checked {
|
||||||
|
border-color: var(--app-primary-color)!important;
|
||||||
|
background-color: var(--app-primary-color)!important
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover:before {
|
||||||
|
background-color: var(--app-primary-color)!important
|
||||||
|
}
|
||||||
|
|
||||||
.ant-slider-handle {
|
.ant-slider-handle {
|
||||||
background-color: #521620 !important;
|
background-color: var(--app-primary-color, #a72b77) !important;
|
||||||
|
border-color: var(--app-tertiary-color, #e0be7f) !important;
|
||||||
|
}
|
||||||
|
.ant-btn-primary{
|
||||||
|
background-color: var(--app-primary-color, #a72b77) !important;
|
||||||
|
color: var(--app-tertiary-color, #ffffff) !important;
|
||||||
|
border-color: var(--app-primary-color, #a72b77) !important;;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-drawer-body{
|
.ant-drawer-body{
|
||||||
padding: 0px !important;
|
padding: 0px !important;
|
||||||
}
|
}
|
||||||
.ant-radio-group-solid .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover{
|
.ant-radio-group-solid .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover{
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
.ant-spin{
|
||||||
.ant-spin-dot-item,
|
color: var(--app-tertiary-color, #000000) !important;
|
||||||
.ant-spin-dot i {
|
|
||||||
background-color: @primary-color !important;
|
|
||||||
}
|
}
|
||||||
|
.ant-spin-dot-item{
|
||||||
|
background-color: var(--app-tertiary-color, #a72b77) !important;
|
||||||
|
|
||||||
|
}
|
||||||
|
.ant-btn:hover{
|
||||||
|
color: var(--app-primary-color, #a72b77) !important;
|
||||||
|
border-color: var(--app-primary-color, #a72b77) !important;;
|
||||||
|
|
||||||
/* لودینگ محتوای صفحه — وسط ناحیه محتوا (موبایل و دسکتاپ یکسان) */
|
|
||||||
.app-page-content-loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
min-height: min(52vh, 22rem);
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
@prefixCls: 'noname';
|
@prefixCls: 'noname';
|
||||||
|
|
||||||
@primary-color: #521620;
|
/* مقادیر ثابت برای Ant Design Less (fade و توابع رنگ). تم واقعی در runtime با --app-primary-color ست میشود. */
|
||||||
@secondary-color: #521620;
|
@primary-color: #a72b77;
|
||||||
|
@secondary-color: #a72b77;
|
||||||
|
@App-primary-color: #a72b77;
|
||||||
@prefixCls-font-family: 'iran-sans';
|
@prefixCls-font-family: 'iran-sans';
|
||||||
@prefixCls-background-color: #fff;
|
@prefixCls-background-color: #fff;
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 450 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 583 KiB |
@@ -2,7 +2,7 @@
|
|||||||
<AButton :disabled="props.disabledB" :type="props.type" :size="props.size" :class="[prefixCls, _class]" :loading="props.loading">
|
<AButton :disabled="props.disabledB" :type="props.type" :size="props.size" :class="[prefixCls, _class]" :loading="props.loading">
|
||||||
<!-- py-1 px-2-->
|
<!-- py-1 px-2-->
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1 justify-center flex-row-reverse gap-2 py-1 px-2"
|
class="flex items-center gap-1 justify-center flex-row-reverse gap-2 px-2"
|
||||||
:class="[{ 'flex-row-reverse': suffixIcon }]"
|
:class="[{ 'flex-row-reverse': suffixIcon }]"
|
||||||
>
|
>
|
||||||
<slot v-if="$slots.default"></slot>
|
<slot v-if="$slots.default"></slot>
|
||||||
@@ -41,11 +41,6 @@ const _class = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const _loading = computed(() => {
|
|
||||||
if (props.loading){
|
|
||||||
return 'flex'
|
|
||||||
} else return 'unset'
|
|
||||||
})
|
|
||||||
const { prefixCls } = usePrefix('base-button')
|
const { prefixCls } = usePrefix('base-button')
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -58,9 +53,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
<style lang="less">
|
<style lang="less">
|
||||||
@prefix:~'@{prefixCls}-base-button';
|
@prefix:~'@{prefixCls}-base-button';
|
||||||
.@{prefix}{
|
.@{prefix}{
|
||||||
//width: fit-content;
|
|
||||||
height: auto;
|
height: auto;
|
||||||
/*padding: 8px;*/
|
|
||||||
display: v-bind(_loading);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import icons from '@/components/BasicPacks/tools/icons/icons.json'
|
import icons from '@/components/BasicPacks/tools/icons/icons.json'
|
||||||
import {usePrefix} from "@/utils/usePrefix";
|
import {usePrefix} from "@/utils/usePrefix";
|
||||||
import {computed, onMounted, ref, withDefaults, defineProps} from "vue";
|
import {computed, onMounted, ref, withDefaults, defineProps} from "vue";
|
||||||
|
import { getAppPrimaryColor } from '@/config/brand-theme'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon: string
|
icon: string
|
||||||
@@ -28,6 +29,11 @@ const tempIcon = ref<string>()
|
|||||||
const refIcon = ref<HTMLDivElement>()
|
const refIcon = ref<HTMLDivElement>()
|
||||||
tempIcon.value = icons[`${props.icon}`] as string
|
tempIcon.value = icons[`${props.icon}`] as string
|
||||||
const color = computed(() => {
|
const color = computed(() => {
|
||||||
|
if (!props.color) return props.color
|
||||||
|
const raw = String(props.color).trim()
|
||||||
|
if (raw.includes('--app-primary-color') || raw === 'primary') {
|
||||||
|
return getAppPrimaryColor()
|
||||||
|
}
|
||||||
return props.color
|
return props.color
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
import {usePrefix} from "@/utils/usePrefix";
|
import {usePrefix} from "@/utils/usePrefix";
|
||||||
import {computed} from "vue";
|
import {computed} from "vue";
|
||||||
import {usePrice} from "@/components/BasicPacks/BasicPriceInput/src/utils/usePrice";
|
import {usePrice} from "@/components/BasicPacks/BasicPriceInput/src/utils/usePrice";
|
||||||
|
import {persianToEnglishDigits, shouldNormalizeInputDigits} from '@/utils/persianDigits';
|
||||||
|
import { getAppPrimaryColor } from '@/config/brand-theme'
|
||||||
|
|
||||||
//Props
|
//Props
|
||||||
interface Prop {
|
interface Prop {
|
||||||
@@ -54,31 +56,54 @@ interface Prop {
|
|||||||
placeHolder?: String
|
placeHolder?: String
|
||||||
}
|
}
|
||||||
|
|
||||||
const addonBgColor = computed(() => props.addonBgColor)
|
function resolveCssColor(value?: String | string | null) {
|
||||||
const addonTextColor = computed(() => props.addonTextColor)
|
if (!value) return value
|
||||||
//emits
|
const raw = String(value).trim()
|
||||||
const localValue = computed({
|
if (raw.includes('--app-primary-color') || raw === 'primary') {
|
||||||
set(value) {
|
return getAppPrimaryColor()
|
||||||
// console.log(value?.toString().replaceAll(',', ''))
|
}
|
||||||
props.type === 'price' ? emits('update:value', value?.toString().replaceAll(',', '')) : emits('update:value', value)
|
return raw
|
||||||
},
|
}
|
||||||
get() {
|
|
||||||
let a = props.type === 'price' ? props.value ? usePrice(props.value as number, true, '').trim() : '' : props.value
|
|
||||||
// console.log(props.value)
|
|
||||||
return a
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Prop>(), {
|
const props = withDefaults(defineProps<Prop>(), {
|
||||||
isPassword: false
|
isPassword: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const addonBgColorResolved = computed(() => resolveCssColor(props.addonBgColor) || getAppPrimaryColor())
|
||||||
|
const addonTextColorResolved = computed(() => resolveCssColor(props.addonTextColor) || 'white')
|
||||||
|
|
||||||
const emits = defineEmits(['update:value', 'onchange'])
|
const emits = defineEmits(['update:value', 'onchange'])
|
||||||
|
|
||||||
|
const localValue = computed({
|
||||||
|
set(value) {
|
||||||
|
emits('update:value', normalizeValue(value))
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return props.type === 'price'
|
||||||
|
? props.value ? usePrice(props.value as number, true, '').trim() : ''
|
||||||
|
: props.value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeValue(value: unknown) {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = String(value)
|
||||||
|
if (shouldNormalizeInputDigits(props)) {
|
||||||
|
normalized = persianToEnglishDigits(normalized)
|
||||||
|
}
|
||||||
|
if (props.type === 'price') {
|
||||||
|
normalized = normalized.replaceAll(',', '')
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
const handlerChange = (e: any) => {
|
const handlerChange = (e: any) => {
|
||||||
// emits('onchange', e.target.value)
|
const normalized = normalizeValue(e.target.value)
|
||||||
props.type === 'price' ? emits('update:value', e.target.value?.toString().replaceAll(',', '')) : emits('update:value', e.target.value)
|
emits('update:value', normalized)
|
||||||
props.type === 'price' ? emits('onchange', e.target.value?.toString().replaceAll(',', '')) : emits('onchange', e.target.value)
|
emits('onchange', normalized)
|
||||||
// console.log('sas')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//css style
|
//css style
|
||||||
@@ -89,11 +114,6 @@ const {prefixCls} = usePrefix('input')
|
|||||||
@prefix: ~'@{prefixCls}-input';
|
@prefix: ~'@{prefixCls}-input';
|
||||||
|
|
||||||
.@{prefix} {
|
.@{prefix} {
|
||||||
/* .ant-input-group-addon:first-child {
|
|
||||||
!* border: #d9d9d9 1px solid;
|
|
||||||
border-radius: 0 6px 6px 0;*!
|
|
||||||
}*/
|
|
||||||
|
|
||||||
.ant-input-group {
|
.ant-input-group {
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
}
|
}
|
||||||
@@ -109,8 +129,8 @@ const {prefixCls} = usePrefix('input')
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ant-input-group-addon {
|
.ant-input-group-addon {
|
||||||
background-color: v-bind(addonBgColor);
|
background-color: v-bind(addonBgColorResolved);
|
||||||
color: v-bind(addonTextColor);
|
color: v-bind(addonTextColorResolved);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicInput v-model:value="_val">
|
<BasicInput v-model:value="_val" type="price">
|
||||||
<template v-for="(_, slotKey) in $slots" #[slotKey]="data">
|
<template v-for="(_, slotKey) in $slots" #[slotKey]="data">
|
||||||
<slot :name="slotKey" v-bind="data || {}"></slot>
|
<slot :name="slotKey" v-bind="data || {}"></slot>
|
||||||
</template>
|
</template>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import {usePrice} from './utils/usePrice'
|
import {usePrice} from './utils/usePrice'
|
||||||
import {computed} from "vue";
|
import {computed} from "vue";
|
||||||
import BasicInput from "../../BasicInput.vue";
|
import BasicInput from "../../BasicInput.vue";
|
||||||
|
import {persianToEnglishDigits} from '@/utils/persianDigits'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string | number
|
value?: string | number
|
||||||
@@ -23,9 +24,7 @@ const props = withDefaults(defineProps<Props>(), {})
|
|||||||
|
|
||||||
const _val = computed({
|
const _val = computed({
|
||||||
set(val: string) {
|
set(val: string) {
|
||||||
// const temp = val.toString().replaceAll(',', '')
|
emit('update:value', persianToEnglishDigits(val).replaceAll(',', ''))
|
||||||
const temp = val.toString().replaceAll(',', '')
|
|
||||||
emit('update:value', temp)
|
|
||||||
},
|
},
|
||||||
get() {
|
get() {
|
||||||
return props.value ? usePrice(props.value as number, true, '').trim() : ''
|
return props.value ? usePrice(props.value as number, true, '').trim() : ''
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div>
|
<div>
|
||||||
{{ props.title }}
|
{{ props.title }}
|
||||||
@@ -105,7 +105,7 @@ const handleChange = (e: any) => {
|
|||||||
//padding: 5px;
|
//padding: 5px;
|
||||||
/* .ant-select {
|
/* .ant-select {
|
||||||
.ant-select-selector {
|
.ant-select-selector {
|
||||||
border-color: @primary-color;
|
border-color: var(--app-primary-color, #a72b77);
|
||||||
border-radius: @border-radius-base;
|
border-radius: @border-radius-base;
|
||||||
}
|
}
|
||||||
}*/
|
}*/
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<img
|
||||||
|
:src="currentSrc"
|
||||||
|
:class="[imgClass, dimmed && 'product-image--dimmed']"
|
||||||
|
:alt="alt"
|
||||||
|
@error="handleError"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
getProductFallbackImage,
|
||||||
|
resolveProductImageSrc,
|
||||||
|
type ProductImageSource
|
||||||
|
} from '@/utils/productImage'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
product?: ProductImageSource
|
||||||
|
imgClass?: string
|
||||||
|
alt?: string
|
||||||
|
dimmed?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
imgClass: 'max-h-full max-w-full object-contain',
|
||||||
|
alt: '',
|
||||||
|
dimmed: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentSrc = ref(resolveProductImageSrc(props.product))
|
||||||
|
const usingFallback = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.product,
|
||||||
|
(product) => {
|
||||||
|
usingFallback.value = false
|
||||||
|
currentSrc.value = resolveProductImageSrc(product)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleError() {
|
||||||
|
if (usingFallback.value) return
|
||||||
|
usingFallback.value = true
|
||||||
|
currentSrc.value = getProductFallbackImage(props.product)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-image--dimmed {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { getProducts } from '@/api/products'
|
||||||
|
import type { Product } from '@/models/products'
|
||||||
|
import { getLastStreamProductsList } from '@/utils/productStreamBridge'
|
||||||
|
|
||||||
|
/** کش مشترک لیست محصولات — dedupe درخواستهای polling */
|
||||||
|
const catalogProducts = ref<Product[]>([])
|
||||||
|
let catalogPromise: Promise<Product[]> | null = null
|
||||||
|
|
||||||
|
export function getProductsCatalogRef() {
|
||||||
|
return catalogProducts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findProductInCatalog(id: number): Product | undefined {
|
||||||
|
const cached = catalogProducts.value.find((p) => p.id === id)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
return getLastStreamProductsList()?.find((p) => p.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductsCatalog(force = false): Promise<Product[]> {
|
||||||
|
if (!force) {
|
||||||
|
const streamed = getLastStreamProductsList()
|
||||||
|
if (streamed?.length) {
|
||||||
|
catalogProducts.value = streamed
|
||||||
|
return Promise.resolve(streamed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalogProducts.value.length > 0) {
|
||||||
|
return Promise.resolve(catalogProducts.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && catalogPromise) {
|
||||||
|
return catalogPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogPromise = getProducts()
|
||||||
|
.then((products) => {
|
||||||
|
catalogProducts.value = products ?? []
|
||||||
|
return catalogProducts.value
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
catalogPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return catalogPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearProductsCatalog() {
|
||||||
|
catalogProducts.value = []
|
||||||
|
catalogPromise = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { getWalletAssets } from '@/api/wallet'
|
||||||
|
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
|
||||||
|
import { useWalletStore } from '@/store/walletStore'
|
||||||
|
import type { WalletAssetsSummary } from '@/models/walletAssets'
|
||||||
|
import { enrichInventoryCardsWithCatalog } from '@/utils/productAssets'
|
||||||
|
|
||||||
|
const assetsSummary = ref<WalletAssetsSummary | null>(null)
|
||||||
|
let assetsPromise: Promise<WalletAssetsSummary> | null = null
|
||||||
|
|
||||||
|
function enrichAssetsSummary(data: WalletAssetsSummary): WalletAssetsSummary {
|
||||||
|
const catalog = getProductsCatalogRef().value
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
inventory_cards: enrichInventoryCardsWithCatalog(data.inventory_cards ?? [], catalog ?? []),
|
||||||
|
count_products: enrichInventoryCardsWithCatalog(data.count_products ?? [], catalog ?? []),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncWalletStoreFromAssets(data: WalletAssetsSummary) {
|
||||||
|
const walletStore = useWalletStore()
|
||||||
|
if (!walletStore.walletInfo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const goldMetal = data.metal_totals?.find((metal) => metal.metal_id === 1) ?? data.metal_totals?.[0]
|
||||||
|
|
||||||
|
walletStore.walletInfo.cash = data.cash_wallet ?? walletStore.walletInfo.cash
|
||||||
|
|
||||||
|
if (goldMetal) {
|
||||||
|
walletStore.walletInfo.gold = goldMetal.melted_weight ?? walletStore.walletInfo.gold
|
||||||
|
walletStore.walletInfo.gold_sum = goldMetal.total_weight ?? walletStore.walletInfo.gold_sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWalletAssets() {
|
||||||
|
async function loadWalletAssets(force = false) {
|
||||||
|
if (!force && assetsSummary.value) {
|
||||||
|
return assetsSummary.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && assetsPromise) {
|
||||||
|
return assetsPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsPromise = Promise.all([getWalletAssets(), getProductsCatalog(force)])
|
||||||
|
.then(([data]) => {
|
||||||
|
assetsSummary.value = enrichAssetsSummary(data)
|
||||||
|
syncWalletStoreFromAssets(assetsSummary.value)
|
||||||
|
return assetsSummary.value
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
assetsPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return assetsPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWalletAssets() {
|
||||||
|
assetsSummary.value = null
|
||||||
|
assetsPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetsSummary,
|
||||||
|
loadWalletAssets,
|
||||||
|
clearWalletAssets,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,74 @@
|
|||||||
|
import { getConfig } from '@/config/app-config'
|
||||||
|
import { getAppIconUrl, resolveConfigAssetUrl } from '@/config/brand-assets'
|
||||||
|
import { applyBrandTheme } from '@/config/brand-theme'
|
||||||
|
import { isPwaManifestReady } from '@/modules/pwa/manifest'
|
||||||
|
|
||||||
|
export function applyAppConfig() {
|
||||||
|
applyBrandTheme()
|
||||||
|
|
||||||
|
if (isPwaManifestReady()) return
|
||||||
|
|
||||||
|
setTitle()
|
||||||
|
setFavicon()
|
||||||
|
setAppleTouchIconFallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitle() {
|
||||||
|
document.title = getConfig('name_fa', 'در حال بارگذاری...')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = getAppIconUrl()
|
||||||
|
|
||||||
|
faviconLink.type = 'image/png'
|
||||||
|
faviconLink.href = logoUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAppleTouchIconFallback() {
|
||||||
|
const logoUrl = getAppIconUrl()
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
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', 'اپ مشتری')
|
||||||
|
}
|
||||||
|
|
||||||
|
export { resolveConfigAssetUrl }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { getConfig } from '@/config/app-config'
|
||||||
|
|
||||||
|
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||||
|
|
||||||
|
const DEFAULT_ICON = '/favicon.png'
|
||||||
|
const DEFAULT_LOGO = '/logo.png'
|
||||||
|
const DEFAULT_LOGO_FULL = '/logo_full_.png'
|
||||||
|
|
||||||
|
export function resolveConfigAssetUrl(path?: string | null): string {
|
||||||
|
if (!path) return ''
|
||||||
|
|
||||||
|
if (/^(https?:\/\/|data:|blob:)/i.test(path)) return path
|
||||||
|
|
||||||
|
const lastSuccess = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
const base = lastSuccess
|
||||||
|
? lastSuccess.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
|
||||||
|
: 'https://nerkh.gold'
|
||||||
|
|
||||||
|
return `${base}/${path.replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickConfigAsset(...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = getConfig<string>(key)
|
||||||
|
if (value) return resolveConfigAssetUrl(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آیکون کوچک اپ — معمولاً فیلد avatar در get_user */
|
||||||
|
export function getAppIconUrl(fallback = DEFAULT_ICON): string {
|
||||||
|
return pickConfigAsset('avatar', 'icon') || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/** لوگوی فشرده — برای هدر و FAB */
|
||||||
|
export function getAppLogoUrl(fallback = DEFAULT_LOGO): string {
|
||||||
|
return pickConfigAsset('logo', 'avatar') || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/** لوگوی کامل — برای اسپلش، لاگین و سایدبار */
|
||||||
|
export function getAppLogoFullUrl(fallback = DEFAULT_LOGO_FULL): string {
|
||||||
|
return pickConfigAsset('logo_full', 'logo', 'avatar') || fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { getConfig } from '@/config/app-config'
|
||||||
|
import { colors } from '@/themeConfig'
|
||||||
|
|
||||||
|
export const DEFAULT_PRIMARY_COLOR = '#a72b77'
|
||||||
|
export const DEFAULT_SECONDARY_COLOR = '#a72b77'
|
||||||
|
export const DEFAULT_TERTIARY_COLOR = '#e0be7f'
|
||||||
|
export const DEFAULT_BACKGROUND_COLOR = '#ffffff'
|
||||||
|
|
||||||
|
function normalizeHex(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
|
||||||
|
|
||||||
|
if (!/^#[0-9a-fA-F]{6}$/.test(withHash) && !/^#[0-9a-fA-F]{3}$/.test(withHash)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withHash.length === 4) {
|
||||||
|
const [, r, g, b] = withHash
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return withHash.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightenHex(hex: string, amount = 0.22): string {
|
||||||
|
const normalized = normalizeHex(hex)
|
||||||
|
if (!normalized) return hex
|
||||||
|
|
||||||
|
const num = parseInt(normalized.slice(1), 16)
|
||||||
|
const r = (num >> 16) & 255
|
||||||
|
const g = (num >> 8) & 255
|
||||||
|
const b = num & 255
|
||||||
|
|
||||||
|
const mix = (channel: number) => Math.round(channel + (255 - channel) * amount)
|
||||||
|
|
||||||
|
return `#${[mix(r), mix(g), mix(b)]
|
||||||
|
.map((channel) => channel.toString(16).padStart(2, '0'))
|
||||||
|
.join('')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function darkenHex(hex: string, amount = 0.22): string {
|
||||||
|
const normalized = normalizeHex(hex)
|
||||||
|
if (!normalized) return hex
|
||||||
|
|
||||||
|
const num = parseInt(normalized.slice(1), 16)
|
||||||
|
const r = Math.round(((num >> 16) & 255) * (1 - amount))
|
||||||
|
const g = Math.round(((num >> 8) & 255) * (1 - amount))
|
||||||
|
const b = Math.round((num & 255) * (1 - amount))
|
||||||
|
|
||||||
|
return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, '0')).join('')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppPrimaryColor(fallback = DEFAULT_PRIMARY_COLOR): string {
|
||||||
|
return (
|
||||||
|
normalizeHex(getConfig('color')) ||
|
||||||
|
normalizeHex(getConfig('primary_color')) ||
|
||||||
|
normalizeHex(getConfig('primary')) ||
|
||||||
|
fallback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppSecondaryColor(fallback = DEFAULT_SECONDARY_COLOR): string {
|
||||||
|
return (
|
||||||
|
normalizeHex(getConfig('secondary_color')) ||
|
||||||
|
normalizeHex(getConfig('secondary')) ||
|
||||||
|
getAppPrimaryColor(fallback)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppTertiaryColor(fallback = DEFAULT_TERTIARY_COLOR): string {
|
||||||
|
return (
|
||||||
|
normalizeHex(getConfig('tertiary_color')) ||
|
||||||
|
normalizeHex(getConfig('tertiary')) ||
|
||||||
|
fallback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppBackgroundColor(fallback = DEFAULT_BACKGROUND_COLOR): string {
|
||||||
|
return (
|
||||||
|
normalizeHex(getConfig('background_color')) ||
|
||||||
|
normalizeHex(getConfig('background')) ||
|
||||||
|
fallback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppPrimaryDark(fallback = '#711d4d'): string {
|
||||||
|
return darkenHex(getAppPrimaryColor(), 0.22) || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppPrimaryLight(fallback = '#c45f9a'): string {
|
||||||
|
return lightenHex(getAppPrimaryColor(), 0.22) || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCssVar(name: string, value: string) {
|
||||||
|
document.documentElement.style.setProperty(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBrandTheme() {
|
||||||
|
const primary = getAppPrimaryColor()
|
||||||
|
const secondary = getAppSecondaryColor()
|
||||||
|
const tertiary = getAppTertiaryColor()
|
||||||
|
const background = getAppBackgroundColor()
|
||||||
|
const primaryDark = getAppPrimaryDark()
|
||||||
|
const primaryLight = getAppPrimaryLight()
|
||||||
|
|
||||||
|
setCssVar('--app-primary-color', primary)
|
||||||
|
setCssVar('--app-secondary-color', secondary)
|
||||||
|
setCssVar('--app-tertiary-color', tertiary)
|
||||||
|
setCssVar('--app-background-color', background)
|
||||||
|
setCssVar('--app-primary-dark', primaryDark)
|
||||||
|
setCssVar('--app-primary-light', primaryLight)
|
||||||
|
|
||||||
|
colors.primary = primary
|
||||||
|
colors['primary-rgba'] = primary
|
||||||
|
colors.secondary = secondary
|
||||||
|
colors['secondary-rgba'] = secondary
|
||||||
|
colors.tertiary = tertiary
|
||||||
|
colors['tertiary-rgba'] = tertiary
|
||||||
|
|
||||||
|
const themeMeta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
|
||||||
|
if (themeMeta) {
|
||||||
|
themeMeta.content = primary
|
||||||
|
} else {
|
||||||
|
const meta = document.createElement('meta')
|
||||||
|
meta.name = 'theme-color'
|
||||||
|
meta.content = primary
|
||||||
|
document.head.appendChild(meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import type { AppConfig } from '@/config/types'
|
||||||
|
import { resetVersionCheckCache } from '@/config/load-version'
|
||||||
|
import { clearCachedPwaManifest } from '@/modules/pwa/manifest-cache'
|
||||||
|
import { tokenName } from '@/themeConfig'
|
||||||
|
|
||||||
|
const CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
|
||||||
|
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||||
|
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
||||||
|
const CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
|
||||||
|
const MAX_COOKIE_VALUE_LENGTH = 3500
|
||||||
|
|
||||||
|
const VERSION_STORAGE_KEY = 'version'
|
||||||
|
const VERSION_UPDATE_LOCK_KEY = 'version_update_lock'
|
||||||
|
const VERSION_LAST_RELOAD_KEY = 'version_last_reload'
|
||||||
|
|
||||||
|
type CachedConfigPayload = {
|
||||||
|
cached_at: number
|
||||||
|
config: AppConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type VersionUpdateLock = {
|
||||||
|
version: string
|
||||||
|
started_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
const cookie = document.cookie.split('; ').find((row) => row.startsWith(`${name}=`))
|
||||||
|
|
||||||
|
if (!cookie) return null
|
||||||
|
|
||||||
|
return cookie.substring(name.length + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(name: string, value: string, maxAgeSeconds: number) {
|
||||||
|
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||||
|
|
||||||
|
document.cookie = [
|
||||||
|
`${name}=${value}`,
|
||||||
|
'Path=/',
|
||||||
|
`Max-Age=${maxAgeSeconds}`,
|
||||||
|
'SameSite=Lax',
|
||||||
|
secure
|
||||||
|
].join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCachedAppConfig(): AppConfig | null {
|
||||||
|
try {
|
||||||
|
const rawCookie = getCookie(CONFIG_COOKIE_NAME)
|
||||||
|
|
||||||
|
if (!rawCookie) return null
|
||||||
|
|
||||||
|
const decoded = decodeURIComponent(rawCookie)
|
||||||
|
const payload = JSON.parse(decoded) as CachedConfigPayload
|
||||||
|
|
||||||
|
if (!payload?.config) return null
|
||||||
|
|
||||||
|
return payload.config
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read cached app config:', error)
|
||||||
|
clearCachedAppConfig()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCachedAppConfig(config: AppConfig) {
|
||||||
|
try {
|
||||||
|
const payload: CachedConfigPayload = {
|
||||||
|
cached_at: Date.now(),
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = encodeURIComponent(JSON.stringify(payload))
|
||||||
|
|
||||||
|
if (encoded.length > MAX_COOKIE_VALUE_LENGTH) {
|
||||||
|
console.warn(`App config is too large for cookie cache. Size: ${encoded.length}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(CONFIG_COOKIE_NAME, encoded, CONFIG_COOKIE_MAX_AGE_SECONDS)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to write app config cache:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCachedAppConfig() {
|
||||||
|
document.cookie = `${CONFIG_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax`
|
||||||
|
clearCachedPwaManifest()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredAppVersion() {
|
||||||
|
return localStorage.getItem(VERSION_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayAppVersion(fallbackVersion: string) {
|
||||||
|
return getStoredAppVersion() || fallbackVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredAppVersion(version: string) {
|
||||||
|
localStorage.setItem(VERSION_STORAGE_KEY, String(version).trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNormalizedVersion(version: unknown) {
|
||||||
|
if (version === null || version === undefined) return ''
|
||||||
|
return String(version).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUpdateForVersion(serverVersion: unknown) {
|
||||||
|
const normalizedServerVersion = getNormalizedVersion(serverVersion)
|
||||||
|
|
||||||
|
if (!normalizedServerVersion) return false
|
||||||
|
|
||||||
|
const storedVersion = getStoredAppVersion()
|
||||||
|
|
||||||
|
if (!storedVersion) {
|
||||||
|
writeStoredAppVersion(normalizedServerVersion)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return storedVersion !== normalizedServerVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canStartVersionUpdate(serverVersion: string) {
|
||||||
|
const version = getNormalizedVersion(serverVersion)
|
||||||
|
|
||||||
|
if (!version) return false
|
||||||
|
|
||||||
|
const lastReloadVersion = sessionStorage.getItem(VERSION_LAST_RELOAD_KEY)
|
||||||
|
|
||||||
|
if (lastReloadVersion === version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLock = sessionStorage.getItem(VERSION_UPDATE_LOCK_KEY)
|
||||||
|
|
||||||
|
if (rawLock) {
|
||||||
|
try {
|
||||||
|
const lock = JSON.parse(rawLock) as VersionUpdateLock
|
||||||
|
const isSameVersion = lock.version === version
|
||||||
|
const isFreshLock = Date.now() - lock.started_at < 60_000
|
||||||
|
|
||||||
|
if (isSameVersion && isFreshLock) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(VERSION_UPDATE_LOCK_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startVersionUpdateLock(serverVersion: string) {
|
||||||
|
const version = getNormalizedVersion(serverVersion)
|
||||||
|
|
||||||
|
const lock: VersionUpdateLock = {
|
||||||
|
version,
|
||||||
|
started_at: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(VERSION_UPDATE_LOCK_KEY, JSON.stringify(lock))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finishVersionUpdateBeforeReload(serverVersion: string) {
|
||||||
|
const version = getNormalizedVersion(serverVersion)
|
||||||
|
|
||||||
|
writeStoredAppVersion(version)
|
||||||
|
|
||||||
|
sessionStorage.setItem(VERSION_LAST_RELOAD_KEY, version)
|
||||||
|
sessionStorage.removeItem(VERSION_UPDATE_LOCK_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelVersionUpdateLock() {
|
||||||
|
sessionStorage.removeItem(VERSION_UPDATE_LOCK_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReadableCookiesExceptConfigAlreadyHandled() {
|
||||||
|
document.cookie.split(';').forEach((cookie) => {
|
||||||
|
const name = cookie.split('=')[0]?.trim()
|
||||||
|
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearBrowserRuntimeCaches() {
|
||||||
|
try {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
|
||||||
|
await Promise.all(registrations.map((registration) => registration.unregister()))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not unregister service workers:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!('caches' in window)) return
|
||||||
|
|
||||||
|
const cacheKeys = await caches.keys()
|
||||||
|
await Promise.all(cacheKeys.map((key) => caches.delete(key)))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not clear browser caches:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalStorageExceptToken() {
|
||||||
|
const token = localStorage.getItem(tokenName)
|
||||||
|
|
||||||
|
localStorage.clear()
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(tokenName, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAppStorageExceptToken() {
|
||||||
|
clearReadableCookiesExceptConfigAlreadyHandled()
|
||||||
|
clearLocalStorageExceptToken()
|
||||||
|
sessionStorage.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllBootstrapFetchCaches() {
|
||||||
|
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
|
||||||
|
clearCachedPwaManifest()
|
||||||
|
resetVersionCheckCache()
|
||||||
|
window.__APP_CONFIG__ = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hardReloadApp() {
|
||||||
|
const refreshUrl = `${window.location.origin}${window.location.pathname}?__app_refresh=${Date.now()}`
|
||||||
|
window.location.href = refreshUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshConfigCacheForVersion(
|
||||||
|
newVersion: string,
|
||||||
|
fetchConfig: () => Promise<AppConfig>
|
||||||
|
) {
|
||||||
|
const normalizedVersion = getNormalizedVersion(newVersion)
|
||||||
|
|
||||||
|
if (!normalizedVersion) {
|
||||||
|
throw new Error('Version is empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
startVersionUpdateLock(normalizedVersion)
|
||||||
|
|
||||||
|
clearCachedAppConfig()
|
||||||
|
clearAppStorageExceptToken()
|
||||||
|
clearAllBootstrapFetchCaches()
|
||||||
|
await clearBrowserRuntimeCaches()
|
||||||
|
|
||||||
|
const freshConfig = await fetchConfig()
|
||||||
|
|
||||||
|
writeCachedAppConfig(freshConfig)
|
||||||
|
finishVersionUpdateBeforeReload(normalizedVersion)
|
||||||
|
|
||||||
|
return freshConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAppAndRefreshConfig(
|
||||||
|
fetchConfig: () => Promise<AppConfig>,
|
||||||
|
version?: string | null
|
||||||
|
) {
|
||||||
|
const normalizedVersion = getNormalizedVersion(version)
|
||||||
|
|
||||||
|
clearCachedAppConfig()
|
||||||
|
clearAppStorageExceptToken()
|
||||||
|
clearAllBootstrapFetchCaches()
|
||||||
|
await clearBrowserRuntimeCaches()
|
||||||
|
|
||||||
|
const freshConfig = await fetchConfig()
|
||||||
|
|
||||||
|
writeCachedAppConfig(freshConfig)
|
||||||
|
|
||||||
|
if (normalizedVersion) {
|
||||||
|
writeStoredAppVersion(normalizedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
return freshConfig
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { AppConfig } from '@/config/types'
|
||||||
|
import { readCachedAppConfig, writeCachedAppConfig } from '@/config/config-cache'
|
||||||
|
|
||||||
|
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||||
|
|
||||||
|
function getProtocol() {
|
||||||
|
return window.location.protocol === 'https:' ? 'https:' : 'http:'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigUrls() {
|
||||||
|
const protocol = getProtocol()
|
||||||
|
|
||||||
|
return [
|
||||||
|
`${protocol}//nerkh.gold/get_user`,
|
||||||
|
`${protocol}//tablo.talasevin.ir/get_user`,
|
||||||
|
`${protocol}//abshode-service.montgit.ir/server/get_user`,
|
||||||
|
`${protocol}//212.23.201.173:8381/get_user`,
|
||||||
|
`${protocol}//91.212.174.137:4040/server/get_user`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortConfigUrls(urls: string[]) {
|
||||||
|
const lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
|
||||||
|
return [...urls].sort((a, b) => {
|
||||||
|
if (a === lastSuccessUrl) return -1
|
||||||
|
if (b === lastSuccessUrl) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Config request failed with status ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
|
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
throw new Error('Config response is not JSON')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFreshAppConfig(): Promise<AppConfig> {
|
||||||
|
const urls = sortConfigUrls(getConfigUrls())
|
||||||
|
|
||||||
|
for (let index = 0; index < urls.length; index++) {
|
||||||
|
const url = urls[index]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timeout = index === urls.length - 1 ? 5000 : 2500
|
||||||
|
const config = await fetchConfig(url, timeout)
|
||||||
|
|
||||||
|
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
|
||||||
|
writeCachedAppConfig(config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Config failed from ${url}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Cannot load app config from any server')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAppConfig(): Promise<AppConfig> {
|
||||||
|
const cachedConfig = readCachedAppConfig()
|
||||||
|
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
return await fetchFreshAppConfig()
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
type VersionResponse =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| {
|
||||||
|
version?: string | number
|
||||||
|
data?: string | number | { version?: string | number; value?: string | number }
|
||||||
|
value?: string | number
|
||||||
|
user?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
||||||
|
const VERSION_RETRY_ROUNDS = 5
|
||||||
|
|
||||||
|
let versionFetchPromise: Promise<string | null> | null = null
|
||||||
|
let cachedVersionResult: string | null | undefined
|
||||||
|
|
||||||
|
function getVersionUrls() {
|
||||||
|
return [
|
||||||
|
'https://nerkh.gold/get_version',
|
||||||
|
'https://tablo.talasevin.ir/get_version',
|
||||||
|
'https://abshode-service.montgit.ir/server/get_version'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortVersionUrls(urls: string[]) {
|
||||||
|
const lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
|
||||||
|
|
||||||
|
if (!lastSuccessUrl) {
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
const others = urls.filter((url) => url !== lastSuccessUrl)
|
||||||
|
return [lastSuccessUrl, ...others]
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUserVersion(user: unknown) {
|
||||||
|
if (!user || typeof user !== 'object' || Array.isArray(user)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = user as { version?: string | number; value?: string | number }
|
||||||
|
const version = record.version ?? record.value
|
||||||
|
|
||||||
|
return version === undefined || version === null ? '' : String(version).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVersionResponse(response: VersionResponse) {
|
||||||
|
if (typeof response === 'string' || typeof response === 'number') {
|
||||||
|
return String(response).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) return ''
|
||||||
|
|
||||||
|
if (typeof response.data === 'string' || typeof response.data === 'number') {
|
||||||
|
return String(response.data).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const directVersion =
|
||||||
|
response.version ??
|
||||||
|
response.value ??
|
||||||
|
(typeof response.data === 'object' ? response.data?.version ?? response.data?.value : undefined)
|
||||||
|
|
||||||
|
if (directVersion !== undefined && directVersion !== null) {
|
||||||
|
return String(directVersion).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return readUserVersion(response.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVersion(url: string, timeout = 3500): Promise<string | null> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Non-2xx/timeout should be treated as "request failed" and we may try the next URL.
|
||||||
|
throw new Error(`Version request failed with status ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
|
|
||||||
|
const data = contentType.includes('application/json')
|
||||||
|
? ((await response.json()) as VersionResponse)
|
||||||
|
: ((await response.text()) as VersionResponse)
|
||||||
|
|
||||||
|
const version = normalizeVersionResponse(data)
|
||||||
|
|
||||||
|
// IMPORTANT: if the request returned 200/ok, we stop here even if we couldn't extract a version.
|
||||||
|
// This prevents unnecessary looping when the endpoint is "reachable" but returns an empty payload.
|
||||||
|
if (!version) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
|
||||||
|
|
||||||
|
return version
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<string | null> {
|
||||||
|
const urls = sortVersionUrls(getVersionUrls())
|
||||||
|
|
||||||
|
for (let round = 1; round <= rounds; round++) {
|
||||||
|
for (let index = 0; index < urls.length; index++) {
|
||||||
|
const url = urls[index]
|
||||||
|
const timeout = index === urls.length - 1 ? 6000 : 3500
|
||||||
|
|
||||||
|
try {
|
||||||
|
const version = await fetchVersion(url, timeout)
|
||||||
|
// fetchVersion only throws on real request failures (non-ok / timeout).
|
||||||
|
// If it returns null, we got HTTP 200 but empty version => stop immediately.
|
||||||
|
return version
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Version failed from ${url}. Round: ${round}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetVersionCheckCache() {
|
||||||
|
cachedVersionResult = undefined
|
||||||
|
versionFetchPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRemoteAppVersion(options?: {
|
||||||
|
rounds?: number
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const rounds = options?.rounds ?? VERSION_RETRY_ROUNDS
|
||||||
|
const force = options?.force ?? false
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
resetVersionCheckCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedVersionResult !== undefined) {
|
||||||
|
return cachedVersionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionFetchPromise) {
|
||||||
|
return versionFetchPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
versionFetchPromise = fetchVersionWithRetry(rounds)
|
||||||
|
.then((version) => {
|
||||||
|
cachedVersionResult = version
|
||||||
|
return version
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
versionFetchPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return versionFetchPromise
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { getConfig } from '@/config/app-config'
|
||||||
|
|
||||||
|
export type ProjectProfileId = 'ghatre' | 'kashkool' | 'boursecu' | 'default'
|
||||||
|
|
||||||
|
export interface ProjectFeatures {
|
||||||
|
id: ProjectProfileId
|
||||||
|
hasHomePage: boolean
|
||||||
|
hasAbshodeTrading: boolean
|
||||||
|
hasProductShop: boolean
|
||||||
|
landingRoute: 'home' | 'buyProduct'
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE_FEATURES: Record<ProjectProfileId, ProjectFeatures> = {
|
||||||
|
ghatre: {
|
||||||
|
id: 'ghatre',
|
||||||
|
hasHomePage: false,
|
||||||
|
hasAbshodeTrading: false,
|
||||||
|
hasProductShop: true,
|
||||||
|
landingRoute: 'buyProduct',
|
||||||
|
},
|
||||||
|
kashkool: {
|
||||||
|
id: 'kashkool',
|
||||||
|
hasHomePage: true,
|
||||||
|
hasAbshodeTrading: true,
|
||||||
|
hasProductShop: true,
|
||||||
|
landingRoute: 'home',
|
||||||
|
},
|
||||||
|
boursecu: {
|
||||||
|
id: 'boursecu',
|
||||||
|
hasHomePage: true,
|
||||||
|
hasAbshodeTrading: true,
|
||||||
|
hasProductShop: true,
|
||||||
|
landingRoute: 'home',
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
id: 'default',
|
||||||
|
hasHomePage: true,
|
||||||
|
hasAbshodeTrading: true,
|
||||||
|
hasProductShop: true,
|
||||||
|
landingRoute: 'home',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSlug(value: unknown): string {
|
||||||
|
return String(value ?? '').toLowerCase().trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAny(haystack: string, needles: string[]): boolean {
|
||||||
|
return needles.some((needle) => haystack.includes(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProjectProfileId(): ProjectProfileId {
|
||||||
|
const name = normalizeSlug(getConfig('name'))
|
||||||
|
const nameFa = String(getConfig('name_fa') ?? '')
|
||||||
|
const mode = normalizeSlug(getConfig('mode'))
|
||||||
|
|
||||||
|
if (
|
||||||
|
matchesAny(name, ['ghatre', 'ghatreh', 'qatre']) ||
|
||||||
|
nameFa.includes('قطره')
|
||||||
|
) {
|
||||||
|
return 'ghatre'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
matchesAny(name, ['boursecu', 'bourse', 'bours']) ||
|
||||||
|
nameFa.includes('بورس')
|
||||||
|
) {
|
||||||
|
return 'boursecu'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
matchesAny(name, ['kashkool', 'kashkol', 'kashkoolgold']) ||
|
||||||
|
nameFa.includes('کشکول')
|
||||||
|
) {
|
||||||
|
return 'kashkool'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesAny(mode, ['ghatre', 'ghatreh', 'qatre'])) return 'ghatre'
|
||||||
|
if (matchesAny(mode, ['boursecu', 'bourse'])) return 'boursecu'
|
||||||
|
if (matchesAny(mode, ['kashkool', 'kashkol'])) return 'kashkool'
|
||||||
|
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectProfile(): ProjectFeatures {
|
||||||
|
return PROFILE_FEATURES[resolveProjectProfileId()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectProfileId(): ProjectProfileId {
|
||||||
|
return resolveProjectProfileId()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectDisplayName(fallback = 'اپ مشتری'): string {
|
||||||
|
return getConfig('name_fa') || getConfig('name') || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultLandingRoute(): 'home' | 'buyProduct' {
|
||||||
|
return getProjectProfile().landingRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasHomePage(): boolean {
|
||||||
|
return getProjectProfile().hasHomePage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAbshodeTrading(): boolean {
|
||||||
|
return getProjectProfile().hasAbshodeTrading
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasProductShop(): boolean {
|
||||||
|
return getProjectProfile().hasProductShop
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGhatreProject(): boolean {
|
||||||
|
return resolveProjectProfileId() === 'ghatre'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKashkoolProject(): boolean {
|
||||||
|
return resolveProjectProfileId() === 'kashkool'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBoursecuProject(): boolean {
|
||||||
|
return resolveProjectProfileId() === 'boursecu'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<DesktopAppLayout v-if="isDesktopLayout" />
|
||||||
|
<MobileAppLayout v-else />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useDesktopBreakpoint } from './composables/useAppLayout'
|
||||||
|
import DesktopAppLayout from './desktop/DesktopAppLayout.vue'
|
||||||
|
import MobileAppLayout from './mobile/MobileAppLayout.vue'
|
||||||
|
|
||||||
|
const isDesktopLayout = useDesktopBreakpoint()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dpage">
|
||||||
|
<header v-if="title" class="dpage-topbar">
|
||||||
|
<div>
|
||||||
|
<h1 class="dpage-topbar__title">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle" class="dpage-topbar__subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<slot name="actions" />
|
||||||
|
</header>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dpage-topbar__subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<header class="dpage-topbar" :class="{ 'dpage-topbar--sticky': sticky }">
|
||||||
|
<button
|
||||||
|
v-if="showBack"
|
||||||
|
type="button"
|
||||||
|
class="dpage-topbar__back"
|
||||||
|
@click="handleBack"
|
||||||
|
>
|
||||||
|
<BasicIcon icon="vuesax-linear:arrow-right-1" size="16" />
|
||||||
|
<span>بازگشت</span>
|
||||||
|
</button>
|
||||||
|
<div class="dpage-topbar__meta">
|
||||||
|
<p v-if="eyebrow" class="dpage-topbar__eyebrow">{{ eyebrow }}</p>
|
||||||
|
<h1 class="dpage-topbar__title">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle" class="dpage-topbar__subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.trailing">
|
||||||
|
<slot name="trailing" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
eyebrow?: string
|
||||||
|
subtitle?: string
|
||||||
|
showBack?: boolean
|
||||||
|
sticky?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showBack: false,
|
||||||
|
sticky: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mpage-header wrap-fix">
|
||||||
|
<button
|
||||||
|
v-if="showBack"
|
||||||
|
type="button"
|
||||||
|
class="mpage-header__back"
|
||||||
|
@click="handleBack"
|
||||||
|
>
|
||||||
|
<BasicIcon icon="vuesax-linear:arrow-right-1" />
|
||||||
|
</button>
|
||||||
|
<div class="mpage-header__title">{{ title }}</div>
|
||||||
|
<div v-if="$slots.trailing" class="mpage-header__trailing">
|
||||||
|
<slot name="trailing" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
showBack?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showBack: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="responsive-view-pair">
|
||||||
|
<div v-show="!isDesktop" class="responsive-view-pair__pane">
|
||||||
|
<slot name="mobile" />
|
||||||
|
</div>
|
||||||
|
<div v-show="isDesktop" class="responsive-view-pair__pane">
|
||||||
|
<slot name="desktop" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useDesktopBreakpoint } from '../composables/useResponsiveLayout'
|
||||||
|
|
||||||
|
const isDesktop = useDesktopBreakpoint()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.responsive-view-pair {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-view-pair__pane {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
|
import { onUnmounted, watch } from 'vue'
|
||||||
|
import { DESKTOP_LAYOUT_MIN_WIDTH } from '../constants'
|
||||||
|
|
||||||
|
const DESKTOP_LAYOUT_CLASS = 'app-layout--desktop'
|
||||||
|
|
||||||
|
export function useDesktopBreakpoint() {
|
||||||
|
return useMediaQuery(`(min-width: ${DESKTOP_LAYOUT_MIN_WIDTH}px)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApplyDesktopLayoutClass() {
|
||||||
|
const isDesktopLayout = useDesktopBreakpoint()
|
||||||
|
|
||||||
|
const stop = watch(
|
||||||
|
isDesktopLayout,
|
||||||
|
(desktop) => {
|
||||||
|
document.documentElement.classList.toggle(DESKTOP_LAYOUT_CLASS, desktop)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
document.documentElement.classList.remove(DESKTOP_LAYOUT_CLASS)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
export function useAppNavigation() {
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const popVisible = ref(false)
|
||||||
|
const currentRoute = computed(() => route.name)
|
||||||
|
const hideMenu = computed(() => !!route.meta.hideMenu)
|
||||||
|
|
||||||
|
function isNavActive(routeName: string) {
|
||||||
|
return currentRoute.value === routeName
|
||||||
|
}
|
||||||
|
|
||||||
|
function navItemClass(routeName: string) {
|
||||||
|
return isNavActive(routeName) ? 'text-primary' : 'text-gray-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(routeName: string) {
|
||||||
|
popVisible.value = false
|
||||||
|
router.push({ name: routeName })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOverflowMenu() {
|
||||||
|
popVisible.value = !popVisible.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOverflowMenu() {
|
||||||
|
popVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
popVisible,
|
||||||
|
currentRoute,
|
||||||
|
hideMenu,
|
||||||
|
isNavActive,
|
||||||
|
navItemClass,
|
||||||
|
navigateTo,
|
||||||
|
toggleOverflowMenu,
|
||||||
|
closeOverflowMenu,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { nextTick } from 'vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
let recoveryTimers: ReturnType<typeof setTimeout>[] = []
|
||||||
|
|
||||||
|
function clearRecoveryTimers() {
|
||||||
|
recoveryTimers.forEach(clearTimeout)
|
||||||
|
recoveryTimers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurFocusedField() {
|
||||||
|
const active = document.activeElement
|
||||||
|
if (active instanceof HTMLElement && active !== document.body) {
|
||||||
|
active.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBodyScrollLock() {
|
||||||
|
document.body.classList.remove('van-overflow-hidden')
|
||||||
|
document.body.style.removeProperty('position')
|
||||||
|
document.body.style.removeProperty('top')
|
||||||
|
document.body.style.removeProperty('left')
|
||||||
|
document.body.style.removeProperty('right')
|
||||||
|
document.body.style.removeProperty('width')
|
||||||
|
document.body.style.removeProperty('height')
|
||||||
|
document.body.style.removeProperty('min-height')
|
||||||
|
document.body.style.removeProperty('overflow')
|
||||||
|
document.body.style.removeProperty('transform')
|
||||||
|
document.documentElement.style.removeProperty('overflow')
|
||||||
|
document.documentElement.style.removeProperty('height')
|
||||||
|
document.documentElement.style.removeProperty('transform')
|
||||||
|
|
||||||
|
const appRoot = document.getElementById('app')
|
||||||
|
if (appRoot) {
|
||||||
|
appRoot.style.removeProperty('position')
|
||||||
|
appRoot.style.removeProperty('top')
|
||||||
|
appRoot.style.removeProperty('height')
|
||||||
|
appRoot.style.removeProperty('min-height')
|
||||||
|
appRoot.style.removeProperty('overflow')
|
||||||
|
appRoot.style.removeProperty('transform')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAllScrollPositions() {
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
document.documentElement.scrollTop = 0
|
||||||
|
document.body.scrollTop = 0
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll('.main-with-bottom-chrome-inner, .login-page, .login-page-root')
|
||||||
|
.forEach((node) => {
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
node.scrollTop = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function kickViewportReflow() {
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runViewportRecovery() {
|
||||||
|
clearBodyScrollLock()
|
||||||
|
resetAllScrollPositions()
|
||||||
|
kickViewportReflow()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleViewportRecovery() {
|
||||||
|
clearRecoveryTimers()
|
||||||
|
runViewportRecovery()
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
runViewportRecovery()
|
||||||
|
|
||||||
|
for (const delay of [50, 150, 300, 500]) {
|
||||||
|
recoveryTimers.push(window.setTimeout(runViewportRecovery, delay))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset scroll offsets after navigation — fixes iOS safe-area drift. */
|
||||||
|
export function resetMobileAppScroll() {
|
||||||
|
runViewportRecovery()
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
requestAnimationFrame(runViewportRecovery)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissMobileKeyboard() {
|
||||||
|
blurFocusedField()
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 120))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareMobileViewportForAppShell() {
|
||||||
|
blurFocusedField()
|
||||||
|
resetAllScrollPositions()
|
||||||
|
await dismissMobileKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full recovery after auth — runs after home shell is mounted. */
|
||||||
|
export function recoverMobileViewportAfterAuth() {
|
||||||
|
scheduleViewportRecovery()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resetMobileAppScrollAfterLogin = recoverMobileViewportAfterAuth
|
||||||
|
|
||||||
|
export async function completeLoginNavigation() {
|
||||||
|
await prepareMobileViewportForAppShell()
|
||||||
|
await router.push({ name: 'buyProduct' })
|
||||||
|
await router.isReady()
|
||||||
|
recoverMobileViewportAfterAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initMobileScrollRestoration() {
|
||||||
|
if (typeof history !== 'undefined' && 'scrollRestoration' in history) {
|
||||||
|
history.scrollRestoration = 'manual'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useDesktopBreakpoint, useApplyDesktopLayoutClass } from './useAppLayout'
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { AppNavItem } from '../types'
|
||||||
|
import {
|
||||||
|
hasAbshodeTrading,
|
||||||
|
hasHomePage,
|
||||||
|
hasProductShop,
|
||||||
|
isKashkoolProject,
|
||||||
|
} from '@/config/project-profile'
|
||||||
|
|
||||||
|
/** تبهای پایین موبایل — کاربر لاگینشده (کشکول و مشابه) */
|
||||||
|
export const AUTH_PRIMARY_NAV: AppNavItem[] = [
|
||||||
|
{ id: 'home', routeName: 'home', label: 'صفحه اصلی', icon: 'vuesax-linear:home' },
|
||||||
|
{ id: 'transactions', routeName: 'transactions', label: 'گزارش', icon: 'vuesax-linear:receipt-item' },
|
||||||
|
{ id: 'wallet', routeName: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-1' },
|
||||||
|
{ id: 'cart', routeName: 'cart', label: 'سبد خرید', icon: 'vuesax-linear:shopping-cart' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** تبهای پایین موبایل — قطره (بدون صفحه اصلی) */
|
||||||
|
export const GHATRE_AUTH_PRIMARY_NAV: AppNavItem[] = [
|
||||||
|
{ id: 'buyProduct', routeName: 'buyProduct', label: 'فروشگاه', icon: 'vuesax-linear:coin' },
|
||||||
|
{ id: 'transactions', routeName: 'transactions', label: 'گزارش', icon: 'vuesax-linear:receipt-item' },
|
||||||
|
{ id: 'wallet', routeName: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-1' },
|
||||||
|
{ id: 'cart', routeName: 'cart', label: 'سبد خرید', icon: 'vuesax-linear:shopping-cart' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** تبهای پایین موبایل — مهمان */
|
||||||
|
export const GUEST_PRIMARY_NAV: AppNavItem[] = [
|
||||||
|
{ id: 'buyProduct', routeName: 'buyProduct', label: 'فروشگاه', icon: 'vuesax-linear:coin' },
|
||||||
|
{ id: 'about', routeName: 'about', label: 'درباره ما', icon: 'vuesax-linear:info-circle' },
|
||||||
|
{ id: 'cart', routeName: 'cart', label: 'سبد خرید', icon: 'vuesax-linear:shopping-cart' },
|
||||||
|
{ id: 'login', routeName: 'login', label: 'ورود', icon: 'vuesax-linear:login' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** منوی FAB موبایل + بخش فرعی سایدبار دسکتاپ — کاربر لاگینشده */
|
||||||
|
export const AUTH_SECONDARY_NAV: AppNavItem[] = [
|
||||||
|
{ id: 'notifyTrigger', routeName: 'notifyTrigger', label: 'هشدار قیمت', icon: 'vuesax-linear:bill' },
|
||||||
|
{ id: 'buyProduct', routeName: 'buyProduct', label: 'فروشگاه', icon: 'vuesax-linear:coin' },
|
||||||
|
{ id: 'fund', routeName: 'fund', label: 'دارایی ها', icon: 'vuesax-linear:archive-1' },
|
||||||
|
{ id: 'user', routeName: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user' },
|
||||||
|
{ id: 'physicalReceive', routeName: 'physicalReceive', label: 'دریافت طلای فیزیکی', icon: 'vuesax-linear:direct-inbox' },
|
||||||
|
{ id: 'list', routeName: 'list', label: 'لیست دریافتیها', icon: 'vuesax-linear:receipt-item' },
|
||||||
|
{ id: 'giftCard', routeName: 'giftCard', label: 'ثبت کارت هدیه', icon: 'vuesax-linear:gift' },
|
||||||
|
{ id: 'earnMoney', routeName: 'earnMoney', label: 'کسب درآمد', icon: 'vuesax-linear:dollar-circle' },
|
||||||
|
{ id: 'about', routeName: 'about', label: 'درباره ما', icon: 'vuesax-linear:info-circle' },
|
||||||
|
{ id: 'rules', routeName: 'rules', label: 'قوانین و مقررات', icon: 'vuesax-linear:task' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** منوی تمامصفحه کشکول — فقط این آیتمها */
|
||||||
|
export const KASHKOOL_OVERFLOW_NAV: AppNavItem[] = [
|
||||||
|
{ id: 'user', routeName: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user' },
|
||||||
|
{ id: 'physicalReceive', routeName: 'physicalReceive', label: 'دریافت طلای فیزیکی', icon: 'vuesax-linear:direct-inbox' },
|
||||||
|
{ id: 'notifyTrigger', routeName: 'notifyTrigger', label: 'هشدار قیمت', icon: 'vuesax-linear:bill' },
|
||||||
|
{ id: 'buyProduct', routeName: 'buyProduct', label: 'خرید محصول', icon: 'vuesax-linear:coin' },
|
||||||
|
{ id: 'giftCard', routeName: 'giftCard', label: 'ثبت کارت هدیه', icon: 'vuesax-linear:gift' },
|
||||||
|
{ id: 'rules', routeName: 'rules', label: 'قوانین و مقررات', icon: 'vuesax-linear:task' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function filterSecondaryNavItems(items: AppNavItem[]): AppNavItem[] {
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (item.routeName === 'buyProduct' && !hasProductShop()) return false
|
||||||
|
if (item.routeName === 'notifyTrigger' && !hasAbshodeTrading()) return false
|
||||||
|
if (item.routeName === 'earnMoney' && !hasAbshodeTrading()) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrimaryNavItems(isAuthenticated: boolean): AppNavItem[] {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return GUEST_PRIMARY_NAV.filter(
|
||||||
|
(item) => item.routeName !== 'buyProduct' || hasProductShop()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasHomePage()) {
|
||||||
|
return GHATRE_AUTH_PRIMARY_NAV
|
||||||
|
}
|
||||||
|
|
||||||
|
return AUTH_PRIMARY_NAV
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSecondaryNavItems(isAuthenticated: boolean): AppNavItem[] {
|
||||||
|
if (!isAuthenticated) return []
|
||||||
|
if (isKashkoolProject()) return KASHKOOL_OVERFLOW_NAV
|
||||||
|
return filterSecondaryNavItems(AUTH_SECONDARY_NAV)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOverflowMenuItems(isAuthenticated: boolean): AppNavItem[] {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return AUTH_SECONDARY_NAV.filter((item) =>
|
||||||
|
['about', 'rules'].includes(item.routeName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isKashkoolProject()) {
|
||||||
|
return KASHKOOL_OVERFLOW_NAV
|
||||||
|
}
|
||||||
|
|
||||||
|
return filterSecondaryNavItems(AUTH_SECONDARY_NAV)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Minimum viewport width for desktop app shell (sidebar layout). */
|
||||||
|
export const DESKTOP_LAYOUT_MIN_WIDTH = 768
|
||||||
|
|
||||||
|
export const DESKTOP_SIDEBAR_WIDTH = 260
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div class="desktop-app-layout">
|
||||||
|
<DesktopSidebar v-if="!hideMenu" />
|
||||||
|
<main
|
||||||
|
class="desktop-app-layout__main"
|
||||||
|
:class="{ 'desktop-app-layout__main--full': hideMenu }"
|
||||||
|
>
|
||||||
|
<router-view v-slot="{ Component, route }">
|
||||||
|
<div :key="route.fullPath" class="desktop-app-layout__content">
|
||||||
|
<component :is="Component" />
|
||||||
|
</div>
|
||||||
|
</router-view>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onUnmounted, watch } from 'vue'
|
||||||
|
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||||
|
import DesktopSidebar from './DesktopSidebar.vue'
|
||||||
|
|
||||||
|
const NO_SIDEBAR_CLASS = 'app-layout--desktop-no-sidebar'
|
||||||
|
const { hideMenu } = useAppNavigation()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
hideMenu,
|
||||||
|
(hidden) => {
|
||||||
|
document.documentElement.classList.toggle(NO_SIDEBAR_CLASS, hidden)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.documentElement.classList.remove(NO_SIDEBAR_CLASS)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" src="./desktop-layout.less"></style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="desktop-nav-item"
|
||||||
|
:class="{ 'desktop-nav-item--active': active }"
|
||||||
|
@click="$emit('click')"
|
||||||
|
>
|
||||||
|
<span class="desktop-nav-item__icon-wrap">
|
||||||
|
<img
|
||||||
|
v-if="iconSrc"
|
||||||
|
:src="iconSrc"
|
||||||
|
alt=""
|
||||||
|
class="desktop-nav-item__icon desktop-nav-item__icon--image"
|
||||||
|
/>
|
||||||
|
<BasicIcon v-else :icon="icon" size="20" class="desktop-nav-item__icon" />
|
||||||
|
<span v-if="badgeCount && badgeCount > 0" class="desktop-nav-item__badge">{{ badgeCount }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="desktop-nav-item__label">{{ label }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
icon: string
|
||||||
|
iconSrc?: string
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
badgeCount?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.desktop-nav-item__icon-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav-item__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: -4px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--app-primary-color, #a72b77);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav-item__icon--image {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
transform: scale(1.75);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="desktop-sidebar">
|
||||||
|
<div class="desktop-sidebar__brand">
|
||||||
|
<img :src="navLogo" :alt="projectName" class="desktop-sidebar__logo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="desktop-sidebar__section" aria-label="منوی اصلی">
|
||||||
|
<DesktopNavItem
|
||||||
|
v-for="item in primaryItems"
|
||||||
|
:key="item.id"
|
||||||
|
:icon="item.icon"
|
||||||
|
:icon-src="item.iconSrc"
|
||||||
|
:label="item.label"
|
||||||
|
:active="isNavActive(item.routeName)"
|
||||||
|
:badge-count="item.routeName === 'cart' && cartCount ? cartCount : undefined"
|
||||||
|
@click="navigateTo(item.routeName)"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div v-if="secondaryItems.length" class="desktop-sidebar__divider" />
|
||||||
|
|
||||||
|
<nav
|
||||||
|
v-if="secondaryItems.length"
|
||||||
|
class="desktop-sidebar__section desktop-sidebar__section--secondary"
|
||||||
|
aria-label="منوی فرعی"
|
||||||
|
>
|
||||||
|
<DesktopNavItem
|
||||||
|
v-for="item in secondaryItems"
|
||||||
|
:key="item.id"
|
||||||
|
:icon="item.icon"
|
||||||
|
:icon-src="item.iconSrc"
|
||||||
|
:label="item.label"
|
||||||
|
:active="isNavActive(item.routeName)"
|
||||||
|
@click="navigateTo(item.routeName)"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { isToken } from '@/utils'
|
||||||
|
import { useCartStore } from '@/store/cartStore'
|
||||||
|
import { getPrimaryNavItems, getSecondaryNavItems } from '../config/navigation'
|
||||||
|
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||||
|
import { getProjectDisplayName } from '@/config/project-profile'
|
||||||
|
import { getAppLogoFullUrl } from '@/config/brand-assets'
|
||||||
|
import DesktopNavItem from './DesktopNavItem.vue'
|
||||||
|
|
||||||
|
const cartStore = useCartStore()
|
||||||
|
const authenticated = computed(() => isToken())
|
||||||
|
const navLogo = getAppLogoFullUrl()
|
||||||
|
const projectName = getProjectDisplayName('کشکول گلد')
|
||||||
|
const primaryItems = computed(() => getPrimaryNavItems(authenticated.value))
|
||||||
|
const secondaryItems = computed(() => getSecondaryNavItems(authenticated.value))
|
||||||
|
const cartCount = computed(() => cartStore.itemCount || 0)
|
||||||
|
|
||||||
|
const { isNavActive, navigateTo } = useAppNavigation()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
.desktop-app-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
background: @prefixCls-background-color;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-app-layout__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: var(--app-desktop-sidebar-width, 260px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-app-layout__main--full {
|
||||||
|
width: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-app-layout__content {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
background: @prefixCls-background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: var(--app-desktop-sidebar-width, 260px);
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 20px 12px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 12px 20px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__logo {
|
||||||
|
max-width: 140px;
|
||||||
|
max-height: 56px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__section--secondary {
|
||||||
|
margin-top: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
margin: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: right;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(167, 43, 119, 0.06);
|
||||||
|
color: var(--app-primary-color, #a72b77);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: rgba(167, 43, 119, 0.1);
|
||||||
|
color: var(--app-primary-color, #a72b77);
|
||||||
|
|
||||||
|
.desktop-nav-item__icon {
|
||||||
|
color: var(--app-primary-color, #a72b77);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav-item__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav-item__icon-wrap {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav-item__label {
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__section--secondary .desktop-nav-item {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/* Shared desktop layouts for inner pages */
|
||||||
|
|
||||||
|
.dinner {
|
||||||
|
min-height: 100%;
|
||||||
|
background: #f6f7f9;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dinner__container {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 36px 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dinner-surface {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cart */
|
||||||
|
.dcart-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcart-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcart-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
|
||||||
|
&__image {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-primary-color, #a72b77);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcart-summary {
|
||||||
|
position: sticky;
|
||||||
|
top: 88px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-primary-color, #a72b77);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product detail */
|
||||||
|
.dproduct-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 380px) minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dproduct-gallery {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 280px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dproduct-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dproduct-info__price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dproduct-info__price {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-primary-color, #a72b77);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dproduct-info__desc {
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #4b5563;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simple form pages (gift card, etc.) */
|
||||||
|
.dform-page {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dform-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Earn money */
|
||||||
|
.dearn-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dearn-stat {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--yellow {
|
||||||
|
background: #fef9c3;
|
||||||
|
color: #ca8a04;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dearn-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dearn-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Physical receive */
|
||||||
|
.dphysical-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dphysical-add {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px dashed rgba(15, 23, 42, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
html.app-layout--desktop {
|
||||||
|
.dpage {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 36px 48px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dpage-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
|
||||||
|
&--sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
margin: -4px 0 20px;
|
||||||
|
padding: 12px 0 20px;
|
||||||
|
background: #f6f7f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__eyebrow {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide mobile fixed headers on desktop inner pages */
|
||||||
|
.fund-info-page-root,
|
||||||
|
.cart-page-root,
|
||||||
|
.gift-card-page-root,
|
||||||
|
.earn-money-page-root,
|
||||||
|
.physical-receive-page-root,
|
||||||
|
.product-detail-page-root,
|
||||||
|
.dinner-page,
|
||||||
|
.cart-page,
|
||||||
|
.gift-card-page,
|
||||||
|
.earn-money-page,
|
||||||
|
.physical-receive-page,
|
||||||
|
.product-detail-page,
|
||||||
|
.about-page-root,
|
||||||
|
.rules-page-root {
|
||||||
|
> .wrap-fix,
|
||||||
|
> div > .wrap-fix,
|
||||||
|
.transaction-list {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-page .grid-cols-2 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1100px) {
|
||||||
|
.products-page .grid-cols-2 {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dpage-surface {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dhome-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
|
||||||
|
&__eyebrow {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dhome-btn-ghost {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #111827;
|
||||||
|
background: rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mobile-app-layout">
|
||||||
|
<MobileOverflowMenu
|
||||||
|
:show="popVisible"
|
||||||
|
@close="closeOverflowMenu"
|
||||||
|
@navigate="navigateTo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div :class="['main-with-bottom-chrome', { 'main-with-bottom-chrome--no-nav': hideMenu }]">
|
||||||
|
<router-view v-slot="{ Component, route: activeRoute }">
|
||||||
|
<transition :name="activeRoute.meta.transition || 'fade'">
|
||||||
|
<component :is="Component" :key="activeRoute.fullPath" class="main-with-bottom-chrome-inner" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<MobileBottomNav
|
||||||
|
v-if="!hideMenu"
|
||||||
|
:nav-item-class="navItemClass"
|
||||||
|
@navigate="navigateTo"
|
||||||
|
@toggle-overflow="toggleOverflowMenu"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||||
|
import { recoverMobileViewportAfterAuth, resetMobileAppScroll } from '../composables/useMobileScrollReset'
|
||||||
|
import MobileBottomNav from './MobileBottomNav.vue'
|
||||||
|
import MobileOverflowMenu from './MobileOverflowMenu.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const {
|
||||||
|
popVisible,
|
||||||
|
hideMenu,
|
||||||
|
navItemClass,
|
||||||
|
navigateTo,
|
||||||
|
toggleOverflowMenu,
|
||||||
|
closeOverflowMenu,
|
||||||
|
} = useAppNavigation()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
resetMobileAppScroll()
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resetMobileAppScroll()
|
||||||
|
recoverMobileViewportAfterAuth()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" src="./mobile-layout.less"></style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bottom-appbar w-auto mx-auto bg-white">
|
||||||
|
<div class="tabs bottom-0">
|
||||||
|
<template v-for="item in leftTabs" :key="item.id">
|
||||||
|
<div :class="navItemClass(item.routeName)" class="tab" @click="$emit('navigate', item.routeName)">
|
||||||
|
<div class="tab__icon">
|
||||||
|
<a-badge v-if="item.routeName === 'cart'" :count="cartCount">
|
||||||
|
<Icon :icon="item.icon" size="24" />
|
||||||
|
</a-badge>
|
||||||
|
<Icon v-else :icon="item.icon" size="24" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs mt-1 font-bold">{{ item.label }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="main_in">
|
||||||
|
<div class="text-white circle_in">
|
||||||
|
<div class="circle_into" @click="$emit('toggle-overflow')">
|
||||||
|
<img
|
||||||
|
class="p-2"
|
||||||
|
:class="{ 'nav-logo--white': isKashkool }"
|
||||||
|
:src="navLogo"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="item in rightTabs" :key="item.id">
|
||||||
|
<div :class="navItemClass(item.routeName)" class="tab" @click="$emit('navigate', item.routeName)">
|
||||||
|
<div class="tab__icon">
|
||||||
|
<a-badge v-if="item.routeName === 'cart'" :count="cartCount">
|
||||||
|
<Icon :icon="item.icon" size="24" />
|
||||||
|
</a-badge>
|
||||||
|
<Icon v-else :icon="item.icon" size="24" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs mt-1 font-bold">{{ item.label }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="circle" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import { isToken } from '@/utils'
|
||||||
|
import { useCartStore } from '@/store/cartStore'
|
||||||
|
import { getAppIconUrl } from '@/config/brand-assets'
|
||||||
|
import { isKashkoolProject } from '@/config/project-profile'
|
||||||
|
import { getPrimaryNavItems } from '../config/navigation'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
navItemClass: (routeName: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
navigate: [routeName: string]
|
||||||
|
'toggle-overflow': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const cartStore = useCartStore()
|
||||||
|
const navLogo = getAppIconUrl()
|
||||||
|
const isKashkool = isKashkoolProject()
|
||||||
|
const cartCount = computed(() => cartStore.itemCount || 0)
|
||||||
|
|
||||||
|
const primaryItems = computed(() => getPrimaryNavItems(isToken()))
|
||||||
|
const leftTabs = computed(() => primaryItems.value.slice(0, 2))
|
||||||
|
const rightTabs = computed(() => primaryItems.value.slice(2))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab__icon :deep(.ant-badge) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo--white {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<Transition>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="top-0 bottom-0 bg-black wrap-fix z-5000 opacity-60"
|
||||||
|
@click="$emit('close')"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
<Transition name="slide-fade">
|
||||||
|
<div v-if="show" class="wrap-fix bottom-11vh z-5000 flex justify-center items-end">
|
||||||
|
<div class="relative flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center bg-white rounded-full w-[30px] h-[30px] mb-2"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<BasicIcon icon="vuesax-linear:close-circle" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<BasicButton
|
||||||
|
v-for="item in menuItems"
|
||||||
|
:key="item.id"
|
||||||
|
icon-size="16"
|
||||||
|
class="!font-bold w-15rem"
|
||||||
|
@click="go(item.routeName)"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<BasicIcon :icon="item.icon" size="16" class="absolute right-2" />
|
||||||
|
<div>{{ item.label }}</div>
|
||||||
|
</div>
|
||||||
|
</BasicButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import { isToken } from '@/utils'
|
||||||
|
import { getOverflowMenuItems } from '../config/navigation'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
navigate: [routeName: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const menuItems = computed(() => getOverflowMenuItems(isToken()))
|
||||||
|
|
||||||
|
function go(routeName: string) {
|
||||||
|
emit('navigate', routeName)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
.bottom-appbar {
|
||||||
|
z-index: var(--app-z-nav, 5000);
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: calc(var(--app-nav-height, 80px) + var(--app-safe-bottom, env(safe-area-inset-bottom, 0px)));
|
||||||
|
padding-bottom: var(--app-safe-bottom, env(safe-area-inset-bottom, 0px));
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
@media screen and (min-width: 426px) {
|
||||||
|
width: 426px;
|
||||||
|
max-width: 426px;
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 400;
|
||||||
|
box-shadow: 0 -2px 4px -2px #0000001a;
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
flex-flow: column;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-content: center;
|
||||||
|
|
||||||
|
&.text-primary .tab__icon,
|
||||||
|
&.text-primary .tab__icon .ant-badge {
|
||||||
|
color: var(--app-primary-color, #a72b77);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.text-gray-400 .tab__icon,
|
||||||
|
&.text-gray-400 .tab__icon .ant-badge {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main_in {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
|
||||||
|
.circle_in {
|
||||||
|
margin-top: -10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--app-primary-color, #a72b77);
|
||||||
|
|
||||||
|
.circle_into {
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--app-primary-color, #a72b77);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border: white 1px solid;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
height: 70px;
|
||||||
|
width: 70px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 70px;
|
||||||
|
width: 70px;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 -5px 4px -2px #0000001a;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-active,
|
||||||
|
.v-leave-active {
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-from,
|
||||||
|
.v-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-enter-active {
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-leave-active {
|
||||||
|
transition: all 0.4s cubic-bezier(1, 0.5, 0.8, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-enter-from,
|
||||||
|
.slide-fade-leave-to {
|
||||||
|
transform: translateX(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-app-layout {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-with-bottom-chrome {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-with-bottom-chrome-inner {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-with-bottom-chrome-inner--contained {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-with-bottom-chrome--no-nav,
|
||||||
|
.main-with-bottom-chrome--no-nav .main-with-bottom-chrome-inner {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
@desktop-layout-min: 768px;
|
||||||
|
|
||||||
|
@media screen and (min-width: @desktop-layout-min) {
|
||||||
|
html {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktopConfig {
|
||||||
|
position: static !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
min-height: 100vh;
|
||||||
|
left: auto !important;
|
||||||
|
right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap-fix {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-appbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.app-layout--desktop {
|
||||||
|
@media screen and (min-width: @desktop-layout-min) {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
body,
|
||||||
|
#app,
|
||||||
|
.desktopConfig {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-config-provider {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
--app-desktop-sidebar-width: 260px;
|
||||||
|
|
||||||
|
.wrap-fix {
|
||||||
|
right: var(--app-desktop-sidebar-width, 260px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#goftino_w {
|
||||||
|
right: 24px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.app-layout--desktop-no-sidebar .wrap-fix {
|
||||||
|
right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-app-layout__content {
|
||||||
|
.w-screen,
|
||||||
|
.md\:w-auto,
|
||||||
|
.md\:w-\[426px\] {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-20 {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dpage {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed.bottom-20,
|
||||||
|
.fixed.bottom-23 {
|
||||||
|
left: var(--app-desktop-sidebar-width, 260px) !important;
|
||||||
|
right: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
max-width: 640px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-list {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Splash — full viewport, centered; never sidebar-offset */
|
||||||
|
.app-splash-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
max-width: none;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
z-index: 1000000000000;
|
||||||
|
|
||||||
|
&__gradient {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&--top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__version {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spinner {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.app-layout--desktop .app-splash-screen {
|
||||||
|
right: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Mobile shell width rules.
|
||||||
|
* Below 426px: full viewport (phones).
|
||||||
|
* 426px–767px: centered 426px frame (tablet-ish mobile).
|
||||||
|
*/
|
||||||
|
|
||||||
|
html:not(.app-layout--desktop) {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktopConfig,
|
||||||
|
.app-main {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main > * {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dock-add-bar,
|
||||||
|
.wallet-card-slider {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 425.98px) {
|
||||||
|
body {
|
||||||
|
display: block !important;
|
||||||
|
width: 100% !important;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app,
|
||||||
|
.desktopConfig,
|
||||||
|
.app-main,
|
||||||
|
.main-with-bottom-chrome,
|
||||||
|
.main-with-bottom-chrome-inner {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap-fix,
|
||||||
|
.bottom-appbar {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dock-add-bar,
|
||||||
|
.wallet-card-slider {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 426px) and (max-width: 767.98px) {
|
||||||
|
body {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktopConfig {
|
||||||
|
width: 426px !important;
|
||||||
|
max-width: 426px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap-fix,
|
||||||
|
.bottom-appbar {
|
||||||
|
width: 426px !important;
|
||||||
|
max-width: 426px !important;
|
||||||
|
left: 50% !important;
|
||||||
|
right: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transform: translateX(-50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dock-add-bar,
|
||||||
|
.wallet-card-slider {
|
||||||
|
width: 426px !important;
|
||||||
|
max-width: 426px !important;
|
||||||
|
left: 50% !important;
|
||||||
|
right: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transform: translateX(-50%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/* Shared mobile page chrome (inner pages — not home .app-header) */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--mpage-header-bar: 2.75rem;
|
||||||
|
--mpage-header-offset: calc(4rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
--mpage-subheader-offset: calc(var(--mpage-header-bar) + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
--mpage-content-with-subheader: calc(6.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
--mpage-scroll-max-height: calc(100dvh - 50px - var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
--mobile-info-support-block: calc(5rem + var(--app-safe-bottom, env(safe-area-inset-bottom, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed top bar: background fills under status bar; content sits below safe area */
|
||||||
|
.mobile-top-chrome {
|
||||||
|
top: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-top: calc(0.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-top-chrome.h-12,
|
||||||
|
.mobile-top-chrome.min-h-12 {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(3rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mpage-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
top: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.75rem;
|
||||||
|
padding-top: calc(0.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
background: #fff;
|
||||||
|
color: var(--app-primary-color, #a72b77);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
z-index: 1999;
|
||||||
|
|
||||||
|
&__back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__trailing {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mpage-subheader {
|
||||||
|
top: var(--mpage-subheader-offset);
|
||||||
|
z-index: 1999;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--app-primary-color, #a72b77);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mpage-content {
|
||||||
|
padding-top: var(--mpage-header-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mpage-content--with-subheader {
|
||||||
|
padding-top: var(--mpage-content-with-subheader);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mpage-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: var(--mpage-scroll-max-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-body--pt10 {
|
||||||
|
padding-top: calc(2.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-body--pt15 {
|
||||||
|
padding-top: calc(3.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-body--pt20 {
|
||||||
|
padding-top: calc(5rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info pages (about, educate): fixed shell, scroll inside content box only */
|
||||||
|
.mobile-info-page {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-info-page__header {
|
||||||
|
z-index: 1999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decorative page background — fixed to viewport, never scrolls on iOS */
|
||||||
|
.mobile-page-bg,
|
||||||
|
.mobile-info-page__bg {
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 0;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: var(--app-bottom-chrome-height, 80px);
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100vw;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-bg--no-nav,
|
||||||
|
.mobile-info-page__bg {
|
||||||
|
bottom: var(--app-credit-footer-offset, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-bg--cover {
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.app-layout--desktop) {
|
||||||
|
@media screen and (min-width: 426px) and (max-width: 767.98px) {
|
||||||
|
.mobile-page-bg,
|
||||||
|
.mobile-info-page__bg {
|
||||||
|
width: 426px;
|
||||||
|
max-width: 426px;
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-info-page__main {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-top: calc(var(--mpage-header-offset) + 1rem);
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
padding-left: 2.5rem;
|
||||||
|
padding-bottom: calc(
|
||||||
|
var(--mobile-info-support-block, 5rem) + var(--app-credit-footer-offset, 0px) + 1.25rem
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-info-page__support {
|
||||||
|
bottom: var(--app-credit-footer-offset, 0px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1rem 2.5rem;
|
||||||
|
padding-bottom: calc(1rem + var(--app-safe-bottom, env(safe-area-inset-bottom, 0px)));
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 -2px 8px rgba(15, 23, 42, 0.06);
|
||||||
|
z-index: 1998;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-content-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-content-box__scroll {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface AppNavItem {
|
||||||
|
id: string
|
||||||
|
routeName: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
iconSrc?: string
|
||||||
|
authOnly?: boolean
|
||||||
|
guestOnly?: boolean
|
||||||
|
}
|
||||||
@@ -1,189 +1,18 @@
|
|||||||
import 'ant-design-vue/dist/antd.less'
|
import 'ant-design-vue/dist/antd.less'
|
||||||
import './assets/css/main.less'
|
import './assets/css/main.less'
|
||||||
import 'virtual:uno.css'
|
import 'virtual:uno.css'
|
||||||
import { computed, createApp, ref, watch } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
import { initPwaInstallCapture } from '@/modules/pwa/manifest'
|
||||||
|
import { bootstrapApp } from '@/app/bootstrap'
|
||||||
|
|
||||||
|
export {
|
||||||
|
setTruth,
|
||||||
|
setEnableAuthenticate,
|
||||||
|
setFingerPosition,
|
||||||
|
setFingerTimeOut
|
||||||
|
} from '@/app/authBoot'
|
||||||
|
|
||||||
import '@unocss/reset/tailwind-compat.css'
|
import '@unocss/reset/tailwind-compat.css'
|
||||||
import VueApexCharts from 'vue3-apexcharts'
|
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
|
||||||
import { authenticateWithFingerprint, getToken, isIphone, removeToken } from '@/utils'
|
|
||||||
import { useUserStore } from '@/store/userStore'
|
|
||||||
import { useWalletStore } from '@/store/walletStore'
|
|
||||||
import { useFundUserStore } from '@/store/fundStore'
|
|
||||||
import VueLazyLoad from 'vue3-lazyload'
|
|
||||||
import { getMe } from '@/api/me'
|
|
||||||
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/store/useAuthStore'
|
|
||||||
|
|
||||||
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 isApple: boolean = isIphone()
|
|
||||||
let isFirst: boolean = true
|
|
||||||
let fingerPosition: boolean = false
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let hasFingerPrint: boolean = false
|
|
||||||
const userInfo = ref()
|
|
||||||
// userInfo.value = await getMe()
|
|
||||||
// const token = getToken()
|
|
||||||
|
|
||||||
const currentRoute = computed(() => {
|
|
||||||
return router.currentRoute.value.fullPath
|
|
||||||
})
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
// console.log('ffffffffffffffffffffffffffffffffffffffffff')
|
|
||||||
if (getToken()) {
|
|
||||||
// await userStore.getMyInfo()
|
|
||||||
await walletStore.getLimitedPrice()
|
|
||||||
await walletStore.getLimitedBuyGold()
|
|
||||||
await fundStore.getMyFund()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setTruth() {
|
|
||||||
fingerPosition = true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setEnableAuthenticate() {
|
|
||||||
fingerPosition = false
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function goToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) {
|
|
||||||
if (to.name === 'buyProduct'||to.name === 'productDetail'||to.name==='about'||to.name==='cart') {
|
|
||||||
return next() // Allow access to buyProduct without login
|
|
||||||
}
|
|
||||||
if (to.name !== 'login') {
|
|
||||||
return next({ name: 'login' }) // Redirect to login
|
|
||||||
} else {
|
|
||||||
return next() // If already on the login page, don't redirect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToHome(to: RouteLocationNormalized, next: NavigationGuardNext) {
|
|
||||||
if (to.name !== 'buyProduct') {
|
|
||||||
return next({ name: 'buyProduct' }) // Redirect to login
|
|
||||||
} else {
|
|
||||||
return next() // If already on the login page, don't redirect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
|
||||||
authStore.setLoading(true)
|
|
||||||
// console.log(authStore)
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
// Start a new timeout to reset fingerprint after 5 seconds of inactivity
|
|
||||||
if (from.name !== 'login' || from.name !== undefined) {
|
|
||||||
// console.log(from.name)
|
|
||||||
timeoutId = setTimeout(async () => {
|
|
||||||
if (hasFingerPrint) {
|
|
||||||
fingerPosition = false
|
|
||||||
/* fingerPosition = await authenticateWithFingerprint()
|
|
||||||
if (!fingerPosition) {
|
|
||||||
goToLogin(to, next)
|
|
||||||
}*/
|
|
||||||
// isFirst = true;
|
|
||||||
console.log('Fingerprint authentication reset after 5 seconds of inactivity')
|
|
||||||
}
|
|
||||||
}, 180000) // 5 seconds
|
|
||||||
}
|
|
||||||
// If it's the first time the navigation guard runs
|
|
||||||
if (isFirst) {
|
|
||||||
isFirst = false // Set isFirst to false after the first check
|
|
||||||
|
|
||||||
// If the user has a token
|
|
||||||
if (getToken()) {
|
|
||||||
await userStore.getMyInfo()
|
|
||||||
const finger_print =userStore.userInfo.finger_print
|
|
||||||
// console.log(finger_print)
|
|
||||||
// const { finger_print } = await getMe()
|
|
||||||
hasFingerPrint = finger_print
|
|
||||||
// If on an Apple device and fingerprint exists
|
|
||||||
if (isApple && finger_print) {
|
|
||||||
removeToken() // Remove token if fingerprint is found
|
|
||||||
goToLogin(to, next) // Redirect to login
|
|
||||||
}
|
|
||||||
// If not an Apple device and fingerprint exists
|
|
||||||
else if (!isApple && finger_print) {
|
|
||||||
// goToLogin(to, next)
|
|
||||||
fingerPosition = await authenticateWithFingerprint()
|
|
||||||
|
|
||||||
if (fingerPosition) {
|
|
||||||
// goToHome(to, next) // Redirect to home if fingerprint is authenticated
|
|
||||||
} else {
|
|
||||||
goToLogin(to, next) // Redirect to login if authentication fails
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// console.log('salam')
|
|
||||||
if (from.path === '/') {
|
|
||||||
// goToHome(to, next)
|
|
||||||
}
|
|
||||||
if(to.name=="login"){
|
|
||||||
goToHome(to, next)
|
|
||||||
}
|
|
||||||
return next()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no token, handle redirect to login or allow access to login, 404, or buyProduct
|
|
||||||
if (['login', '404', 'buyProduct','productDetail','about','cart'].includes(to.name as string)) {
|
|
||||||
return next() // Allow access to login, 404, or buyProduct
|
|
||||||
}
|
|
||||||
goToLogin(to, next) // Redirect to login if no token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For subsequent navigations after the first check
|
|
||||||
else {
|
|
||||||
// If the user has passed fingerprint authentication
|
|
||||||
if (fingerPosition) {
|
|
||||||
if (getToken()) {
|
|
||||||
if (to.name === 'login') {
|
|
||||||
return next({ name: from.name || 'buyProduct' }) // Redirect to home if trying to access login after authentication
|
|
||||||
}
|
|
||||||
return next() // Proceed to the intended route
|
|
||||||
} else {
|
|
||||||
// If no token, ensure proper redirection to login or 404
|
|
||||||
if (['login', '404', 'buyProduct','productDetail','about','cart'].includes(to.name as string)) {
|
|
||||||
return next() // Allow access to login, 404, or buyProduct
|
|
||||||
}
|
|
||||||
return next({ name: 'login' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If fingerprint authentication failed or was not done
|
|
||||||
else {
|
|
||||||
if (getToken() && !hasFingerPrint) {
|
|
||||||
next()
|
|
||||||
} else {
|
|
||||||
goToLogin(to, next)
|
|
||||||
}
|
|
||||||
/* if (to.name !== 'login') {
|
|
||||||
return next({ name: 'login' }) // Redirect to login
|
|
||||||
} else {
|
|
||||||
return next() // If already on the login page, don't redirect
|
|
||||||
}*/
|
|
||||||
/* console.log('sssssssssssssssssssssssss')
|
|
||||||
return next({ name: 'login' })*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.mount('#app')
|
|
||||||
// if (currentRoute.value !== '/') {
|
|
||||||
// await start()
|
|
||||||
// }
|
|
||||||
watch(currentRoute, async () => userStore.userInfo ? '' : await start())
|
|
||||||
|
|
||||||
|
|
||||||
|
initPwaInstallCapture()
|
||||||
|
bootstrapApp()
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function setFingerTimeOut(to: RouteLocationNormalized){
|
|||||||
// console.log('Fingerprint authentication reset after 5 seconds of inactivity');
|
// console.log('Fingerprint authentication reset after 5 seconds of inactivity');
|
||||||
await router.push({ name: 'login' })
|
await router.push({ name: 'login' })
|
||||||
if (to.name !== 'login') {
|
if (to.name !== 'login') {
|
||||||
message.warn('شما مدت زیادی با قطره کار نکردید، لطفا دوباره وارد شوید.')
|
message.warn('شما مدت زیادی با کشکول گلد کار نکردید، لطفا دوباره وارد شوید.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 10000) // 3 minutess
|
}, 10000) // 3 minutess
|
||||||
|
|||||||
@@ -12,8 +12,23 @@ export interface Series {
|
|||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface PriceUp {
|
||||||
|
buy: number
|
||||||
|
sell: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PriceStatus = 'open' | 'closed' | 'no_quote'
|
||||||
|
|
||||||
export interface Price {
|
export interface Price {
|
||||||
sell: number
|
sell: number
|
||||||
buy: number
|
buy: number
|
||||||
price_last: number
|
price: number
|
||||||
}
|
price_last: number
|
||||||
|
goldir?: number
|
||||||
|
up?: PriceUp
|
||||||
|
closed?: boolean
|
||||||
|
status?: PriceStatus
|
||||||
|
time?: string
|
||||||
|
main_product?: number
|
||||||
|
montzar?: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export interface WalletAssetsMeta {
|
||||||
|
metal_ids: number[]
|
||||||
|
metal_count: number
|
||||||
|
has_count_based_products: boolean
|
||||||
|
has_weight_based_products: boolean
|
||||||
|
weight_unit: string
|
||||||
|
single_metal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletInventoryCard {
|
||||||
|
product_id: number
|
||||||
|
title: string
|
||||||
|
group_type: string
|
||||||
|
metal_id: number
|
||||||
|
quantity: number
|
||||||
|
unit: string
|
||||||
|
display_type: 'count' | 'weight'
|
||||||
|
unit_weight?: string | null
|
||||||
|
show_in_assets: boolean
|
||||||
|
is_melted?: boolean
|
||||||
|
is_main_wallet?: boolean
|
||||||
|
file?: { src?: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletMetalTotal {
|
||||||
|
metal_id: number
|
||||||
|
metal_label: string
|
||||||
|
label: string
|
||||||
|
product_weight: number
|
||||||
|
melted_weight: number
|
||||||
|
total_weight: number
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletAssetsSummary {
|
||||||
|
meta: WalletAssetsMeta
|
||||||
|
cash_wallet: number
|
||||||
|
inventory_cards: WalletInventoryCard[]
|
||||||
|
count_products: WalletInventoryCard[]
|
||||||
|
metal_totals: WalletMetalTotal[]
|
||||||
|
assets_rial_value: number
|
||||||
|
total_rial_value: number
|
||||||
|
profit_loss?: number
|
||||||
|
}
|
||||||
@@ -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,558 @@
|
|||||||
|
import type { AppConfig } from '@/config/types'
|
||||||
|
import { getConfig, getRawAppConfig } from '@/config/app-config'
|
||||||
|
import { getConfigUrls, sortConfigUrls } from '@/config/load-config'
|
||||||
|
import {
|
||||||
|
clearCachedPwaManifest,
|
||||||
|
readCachedPwaManifest,
|
||||||
|
writeCachedPwaManifest,
|
||||||
|
type PwaManifest
|
||||||
|
} from '@/modules/pwa/manifest-cache'
|
||||||
|
|
||||||
|
export { clearCachedPwaManifest, type PwaManifest, type PwaManifestIcon } from '@/modules/pwa/manifest-cache'
|
||||||
|
|
||||||
|
export const PWA_MANIFEST_READY_KEY = 'pwa_manifest_ready'
|
||||||
|
export const PWA_MANIFEST_READY_EVENT = 'pwa-manifest-ready'
|
||||||
|
|
||||||
|
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||||
|
let pendingBeforeInstallPrompt: Event | null = null
|
||||||
|
let installCaptureInitialized = false
|
||||||
|
const MANIFEST_RETRY_DELAY_MS = 500
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url: string) {
|
||||||
|
return url.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همان baseای که get_user از آن لود شده (کش شده در localStorage) */
|
||||||
|
export function getConfigBaseFromStorage() {
|
||||||
|
const lastSuccess = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
if (!lastSuccess) return ''
|
||||||
|
return normalizeBaseUrl(lastSuccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
function configUrlToManifestUrl(configUrl: string) {
|
||||||
|
return `${configUrl.replace(/\/+$/, '')}/manifest.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همان ۵ URL پایهٔ get_user — اولویت با CONFIG_Success_URL */
|
||||||
|
export function resolveManifestUrls(config?: AppConfig | null): string[] {
|
||||||
|
const explicit = getConfig<string>('manifest_url')
|
||||||
|
if (explicit) return [explicit]
|
||||||
|
|
||||||
|
return sortConfigUrls(getConfigUrls()).map(configUrlToManifestUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** اولین کاندید — برای سازگاری */
|
||||||
|
export function resolveManifestUrl(config?: AppConfig | null) {
|
||||||
|
return resolveManifestUrls(config)[0] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidManifest(payload: unknown): payload is PwaManifest {
|
||||||
|
if (!payload || typeof payload !== 'object') return false
|
||||||
|
|
||||||
|
const manifest = payload as PwaManifest
|
||||||
|
const icons = manifest.icons
|
||||||
|
|
||||||
|
if (!Array.isArray(icons) || icons.length === 0) return false
|
||||||
|
|
||||||
|
return icons.some((icon) => typeof icon?.src === 'string' && icon.src.trim().length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchManifestOnce(url: string, timeout = 6000): Promise<PwaManifest> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/manifest+json, application/json' },
|
||||||
|
cache: 'default',
|
||||||
|
signal: controller.signal,
|
||||||
|
priority: 'high'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Manifest request failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
if (!isValidManifest(payload)) {
|
||||||
|
throw new Error('Manifest response is missing valid icons')
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** هر URL یک بار — مثل لوپ get_user */
|
||||||
|
async function fetchManifestTryingUrls(
|
||||||
|
urls: string[]
|
||||||
|
): Promise<{ manifest: PwaManifest; manifestUrl: string }> {
|
||||||
|
if (!urls.length) {
|
||||||
|
throw new Error('No manifest URL candidates')
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: unknown = null
|
||||||
|
|
||||||
|
for (let index = 0; index < urls.length; index++) {
|
||||||
|
const url = urls[index]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = await fetchManifestOnce(url)
|
||||||
|
return { manifest, manifestUrl: url }
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
console.warn(`PWA manifest failed (${url}), candidate ${index + 1}/${urls.length}`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < urls.length - 1) {
|
||||||
|
await sleep(MANIFEST_RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error('PWA manifest could not be loaded from any server')
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifestBlobUrl: string | null = null
|
||||||
|
|
||||||
|
function revokeManifestBlobUrl() {
|
||||||
|
if (manifestBlobUrl) {
|
||||||
|
URL.revokeObjectURL(manifestBlobUrl)
|
||||||
|
manifestBlobUrl = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteWebUrl(src: string) {
|
||||||
|
return /^https?:\/\//i.test(src) || src.startsWith('//')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAbsoluteAssetUrl(src: string, base: string) {
|
||||||
|
const value = String(src || '').trim()
|
||||||
|
if (!value) return ''
|
||||||
|
|
||||||
|
if (value.startsWith('//')) {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'
|
||||||
|
return `${protocol}${value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(https?:\/\/|data:|blob:)/i.test(value)) return value
|
||||||
|
if (!base) return value
|
||||||
|
|
||||||
|
return `${base.replace(/\/+$/, '')}/${value.replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** path داخل همین اپ — دامنهٔ سرور (montpar.ir) نادیده گرفته میشود */
|
||||||
|
function toManifestPath(url: string | undefined, fallback = '/') {
|
||||||
|
const defaultPath = fallback.startsWith('/') ? fallback : `/${fallback}`
|
||||||
|
|
||||||
|
if (!url?.trim()) return defaultPath
|
||||||
|
|
||||||
|
try {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
|
||||||
|
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(trimmed)
|
||||||
|
const path = `${parsed.pathname || '/'}${parsed.search}${parsed.hash}`
|
||||||
|
|
||||||
|
if (parsed.origin !== window.location.origin) {
|
||||||
|
return path || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
return path || '/'
|
||||||
|
} catch {
|
||||||
|
return defaultPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* blob manifest فقط URL مطلق همorigin را میپذیرد (مثلاً http://192.168.x.x:3100/).
|
||||||
|
* مسیر نسبی مثل "/" → "property ignored, URL is invalid".
|
||||||
|
*/
|
||||||
|
function toSameOriginManifestUrl(url: string | undefined, fallbackPath = '/') {
|
||||||
|
const path = toManifestPath(url, fallbackPath)
|
||||||
|
return new URL(path, window.location.origin).href
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeManifestId(id: string | undefined, startUrl: string) {
|
||||||
|
const value = String(id || '').trim()
|
||||||
|
if (!value || value === '/') return startUrl
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value, window.location.origin)
|
||||||
|
if (parsed.origin === window.location.origin) return parsed.href
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return startUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessMimeType(src: string) {
|
||||||
|
const lower = src.split('?')[0].toLowerCase()
|
||||||
|
|
||||||
|
if (lower.endsWith('.png')) return 'image/png'
|
||||||
|
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
|
||||||
|
if (lower.endsWith('.webp')) return 'image/webp'
|
||||||
|
|
||||||
|
return 'image/png'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapManifestIcons(manifest: PwaManifest, base: string) {
|
||||||
|
return (manifest.icons || [])
|
||||||
|
.map((icon) => {
|
||||||
|
const src = toAbsoluteAssetUrl(icon.src, base)
|
||||||
|
|
||||||
|
return {
|
||||||
|
src,
|
||||||
|
sizes: icon.sizes || '512x512',
|
||||||
|
type: guessMimeType(src),
|
||||||
|
purpose: icon.purpose || 'any'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((icon) => isAbsoluteWebUrl(icon.src))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فقط آیکونهای داخل monifist.json — بدون آواتار / pwa-icons / env */
|
||||||
|
function resolveInstallIcons(manifest: PwaManifest) {
|
||||||
|
const base = getConfigBaseFromStorage()
|
||||||
|
const mapped = mapManifestIcons(manifest, base)
|
||||||
|
|
||||||
|
if (!mapped.length) {
|
||||||
|
throw new Error('Manifest icons could not be resolved to absolute URLs')
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareManifestForInstall(manifest: PwaManifest) {
|
||||||
|
const scopeUrl = toSameOriginManifestUrl(manifest.scope ?? manifest.start_url, '/')
|
||||||
|
const startUrl = toSameOriginManifestUrl(manifest.start_url, '/')
|
||||||
|
const icons = resolveInstallIcons(manifest)
|
||||||
|
const name = manifest.short_name || manifest.name
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('Manifest is missing name/short_name')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest.theme_color) {
|
||||||
|
throw new Error('Manifest is missing theme_color')
|
||||||
|
}
|
||||||
|
|
||||||
|
// start_url باید داخل scope باشد
|
||||||
|
const scopeBase = scopeUrl.endsWith('/') ? scopeUrl : `${scopeUrl}/`
|
||||||
|
const effectiveScope = startUrl.startsWith(scopeBase) ? scopeUrl : toSameOriginManifestUrl('/', '/')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: normalizeManifestId(manifest.id, startUrl),
|
||||||
|
name,
|
||||||
|
short_name: manifest.short_name || manifest.name || name,
|
||||||
|
lang: manifest.lang || 'fa',
|
||||||
|
dir: manifest.dir || 'rtl',
|
||||||
|
start_url: startUrl,
|
||||||
|
scope: effectiveScope,
|
||||||
|
display: manifest.display || 'standalone',
|
||||||
|
background_color: manifest.background_color || '#ffffff',
|
||||||
|
theme_color: manifest.theme_color,
|
||||||
|
description: manifest.description || name,
|
||||||
|
icons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickManifestIconUrl(manifest: PwaManifest, preferredSizes: number[]) {
|
||||||
|
const icons = manifest.icons ?? []
|
||||||
|
|
||||||
|
for (const size of preferredSizes) {
|
||||||
|
const target = `${size}x${size}`
|
||||||
|
const match = icons.find((icon) => String(icon.sizes || '').includes(target))
|
||||||
|
|
||||||
|
if (match?.src) return match.src
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...icons].sort((a, b) => {
|
||||||
|
const aSize = Number.parseInt(String(a.sizes || '0'), 10) || 0
|
||||||
|
const bSize = Number.parseInt(String(b.sizes || '0'), 10) || 0
|
||||||
|
return bSize - aSize
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted[0]?.src || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertLink(rel: string, attrs: Record<string, string>) {
|
||||||
|
const selectorParts = Object.entries(attrs)
|
||||||
|
.filter(([key]) => key !== 'href')
|
||||||
|
.map(([key, value]) => `[${key}="${value}"]`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]${selectorParts}`)
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link')
|
||||||
|
link.rel = rel
|
||||||
|
document.head.appendChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(attrs).forEach(([key, value]) => {
|
||||||
|
link!.setAttribute(key, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertMeta(name: string, content: string) {
|
||||||
|
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta')
|
||||||
|
meta.name = name
|
||||||
|
document.head.appendChild(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAllManifestLinks() {
|
||||||
|
document.querySelectorAll<HTMLLinkElement>("link[rel='manifest']").forEach((node) => node.remove())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDynamicManifestLinks() {
|
||||||
|
removeAllManifestLinks()
|
||||||
|
revokeManifestBlobUrl()
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
|
||||||
|
.forEach((node) => node.remove())
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
|
||||||
|
document
|
||||||
|
.querySelectorAll<HTMLLinkElement>("link[rel='apple-touch-icon'][data-dynamic-pwa='true']")
|
||||||
|
.forEach((node) => node.remove())
|
||||||
|
|
||||||
|
const appleSizes = [180, 167, 152, 120]
|
||||||
|
const used = new Set<string>()
|
||||||
|
|
||||||
|
appleSizes.forEach((size) => {
|
||||||
|
const href = pickManifestIconUrl(manifest, [size, 192, 512])
|
||||||
|
|
||||||
|
if (!href || used.has(href)) return
|
||||||
|
|
||||||
|
used.add(href)
|
||||||
|
|
||||||
|
upsertLink('apple-touch-icon', {
|
||||||
|
'data-dynamic-pwa': 'true',
|
||||||
|
sizes: `${size}x${size}`,
|
||||||
|
href
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
|
||||||
|
|
||||||
|
if (primary) {
|
||||||
|
upsertLink('apple-touch-icon', {
|
||||||
|
'data-dynamic-pwa': 'true',
|
||||||
|
href: primary
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPreparedManifestToDocument(
|
||||||
|
normalized: PwaManifest,
|
||||||
|
manifestUrl: string
|
||||||
|
) {
|
||||||
|
removeDynamicManifestLinks()
|
||||||
|
|
||||||
|
if (!normalized.icons?.length) {
|
||||||
|
throw new Error('Manifest has no valid absolute icon URLs')
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = normalized.short_name || normalized.name
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
throw new Error('Prepared manifest is missing title')
|
||||||
|
}
|
||||||
|
|
||||||
|
document.title = title
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(normalized)], {
|
||||||
|
type: 'application/manifest+json'
|
||||||
|
})
|
||||||
|
manifestBlobUrl = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
upsertLink('manifest', {
|
||||||
|
'data-dynamic-pwa': 'true',
|
||||||
|
href: manifestBlobUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
if (normalized.theme_color) {
|
||||||
|
upsertMeta('theme-color', normalized.theme_color)
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertMeta('apple-mobile-web-app-title', title)
|
||||||
|
upsertMeta('mobile-web-app-capable', 'yes')
|
||||||
|
upsertMeta('apple-mobile-web-app-capable', 'yes')
|
||||||
|
|
||||||
|
applyAppleTouchIconsFromManifest(normalized)
|
||||||
|
|
||||||
|
const favicon = pickManifestIconUrl(normalized, [192, 180, 128, 96, 72])
|
||||||
|
|
||||||
|
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
|
||||||
|
|
||||||
|
if (favicon) {
|
||||||
|
upsertLink('icon', { type: 'image/png', href: favicon })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyManifestToDocument(manifest: PwaManifest, manifestUrl: string) {
|
||||||
|
const prepared = prepareManifestForInstall(manifest)
|
||||||
|
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPwaManifestReady(enabled: boolean) {
|
||||||
|
if (enabled) {
|
||||||
|
sessionStorage.setItem(PWA_MANIFEST_READY_KEY, '1')
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(PWA_MANIFEST_READY_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPwaManifestReady() {
|
||||||
|
return sessionStorage.getItem(PWA_MANIFEST_READY_KEY) === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** هرچه زودتر در main.ts — قبل از bootstrap */
|
||||||
|
export function initPwaInstallCapture() {
|
||||||
|
if (installCaptureInitialized || typeof window === 'undefined') return
|
||||||
|
installCaptureInitialized = true
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (isPwaManifestReady()) {
|
||||||
|
notifyPwaManifestReady(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingBeforeInstallPrompt = e
|
||||||
|
console.info('PWA install prompt captured, waiting for manifest + service worker')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyPwaManifestReady(installEvent?: Event | null) {
|
||||||
|
if (!isPwaManifestReady()) return
|
||||||
|
|
||||||
|
const event = installEvent ?? pendingBeforeInstallPrompt
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(PWA_MANIFEST_READY_EVENT, {
|
||||||
|
detail: { installEvent: event }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
console.info('PWA ready (manifest + SW). Waiting for beforeinstallprompt…', {
|
||||||
|
secureContext: window.isSecureContext,
|
||||||
|
origin: window.location.origin
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingBeforeInstallPrompt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCachedManifestEntry(
|
||||||
|
cached: ReturnType<typeof readCachedPwaManifest>
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!cached) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prepared = prepareManifestForInstall(cached.manifest)
|
||||||
|
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
|
||||||
|
setPwaManifestReady(true)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cached PWA manifest invalid, refetching:', error)
|
||||||
|
clearCachedPwaManifest()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بعد از loadAppConfig / setAppConfig صدا بزن.
|
||||||
|
* get_user دوباره fetch نمیشود — manifest از همان ۵ URL پایهٔ get_user امتحان میشود.
|
||||||
|
*/
|
||||||
|
export async function setupPwaManifest(config?: AppConfig | null): Promise<boolean> {
|
||||||
|
const raw = config ?? getRawAppConfig()
|
||||||
|
const userId = raw?.user?.id
|
||||||
|
const configBase = getConfigBaseFromStorage()
|
||||||
|
const manifestUrls = resolveManifestUrls(raw)
|
||||||
|
|
||||||
|
if (!manifestUrls.length) {
|
||||||
|
console.warn('PWA manifest skipped: no manifest URL candidates')
|
||||||
|
removeDynamicManifestLinks()
|
||||||
|
setPwaManifestReady(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = configBase ? readCachedPwaManifest(userId, configBase) : null
|
||||||
|
|
||||||
|
if (cached && (await applyCachedManifestEntry(cached))) {
|
||||||
|
console.info('PWA manifest applied from cache:', cached.manifest_url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { manifest, manifestUrl } = await fetchManifestTryingUrls(manifestUrls)
|
||||||
|
|
||||||
|
const prepared = prepareManifestForInstall(manifest)
|
||||||
|
|
||||||
|
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
||||||
|
setPwaManifestReady(true)
|
||||||
|
|
||||||
|
if (userId != null && userId !== '') {
|
||||||
|
const effectiveConfigBase =
|
||||||
|
configBase || normalizeBaseUrl(manifestUrl.replace(/\/manifest\.json$/i, ''))
|
||||||
|
|
||||||
|
writeCachedPwaManifest({
|
||||||
|
cached_at: Date.now(),
|
||||||
|
user_id: userId,
|
||||||
|
config_base: effectiveConfigBase,
|
||||||
|
manifest_url: manifestUrl,
|
||||||
|
manifest: prepared
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('PWA manifest loaded once:', manifestUrl)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('PWA manifest unavailable, PWA disabled this session:', error)
|
||||||
|
|
||||||
|
removeDynamicManifestLinks()
|
||||||
|
setPwaManifestReady(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifestBootstrapStarted = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بلافاصله بعد از setAppConfig — بدون await در bootstrap.
|
||||||
|
* اپ منتظر مانیفست نمیماند؛ SW بعد از موفقیت در پسزمینه ثبت میشود.
|
||||||
|
*/
|
||||||
|
export function startPwaManifest(config?: AppConfig | null) {
|
||||||
|
if (manifestBootstrapStarted) return
|
||||||
|
manifestBootstrapStarted = true
|
||||||
|
|
||||||
|
void setupPwaManifest(config).then((ready) => {
|
||||||
|
if (!ready) return
|
||||||
|
|
||||||
|
void import('@/modules/pwa/register-pwa').then(({ registerAppPwa }) => registerAppPwa())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {createRouter, createWebHistory} from 'vue-router'
|
import {createRouter, createWebHistory} from 'vue-router'
|
||||||
|
import { getDefaultLandingRoute } from '@/config/project-profile'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -17,7 +18,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: `/`,
|
path: `/`,
|
||||||
name: 'main',
|
name: 'main',
|
||||||
redirect: {name: 'buyProduct'},
|
redirect: () => ({ name: getDefaultLandingRoute() }),
|
||||||
component: () => import('../views/base/index.vue'),
|
component: () => import('../views/base/index.vue'),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -88,19 +89,9 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: 'physicalReceive',
|
path: 'physicalReceive',
|
||||||
name: 'physicalReceive',
|
name: 'physicalReceive',
|
||||||
|
redirect: { name: 'list' },
|
||||||
component: () => import('@/views/base/physicalReceive/index.vue'),
|
component: () => import('@/views/base/physicalReceive/index.vue'),
|
||||||
},
|
children: [
|
||||||
{
|
|
||||||
path: 'list',
|
|
||||||
name: 'list',
|
|
||||||
component: () => import('@/views/base/physicalReceive/components/RequestList.vue'),
|
|
||||||
},
|
|
||||||
/* {
|
|
||||||
path: 'physicalReceive',
|
|
||||||
name: 'physicalReceive',
|
|
||||||
redirect:{name: 'list'},
|
|
||||||
component: () => import('@/views/base/physicalReceive/index.vue'),
|
|
||||||
children:[
|
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'list',
|
name: 'list',
|
||||||
@@ -112,7 +103,7 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/base/physicalReceive/components/NewRequest.vue')
|
component: () => import('@/views/base/physicalReceive/components/NewRequest.vue')
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},*/
|
},
|
||||||
{
|
{
|
||||||
path: 'rules',
|
path: 'rules',
|
||||||
name: 'rules',
|
name: 'rules',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { computed, ref } from 'vue'
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { CartItem } from '@/models/cart'
|
import type { CartItem } from '@/models/cart'
|
||||||
import type { Product } from '@/models/products'
|
import type { Product } from '@/models/products'
|
||||||
|
import { getProductsCatalog } from '@/composables/useProductsCatalog'
|
||||||
|
import { getProductBuyPrice } from '@/utils/productPrice'
|
||||||
|
|
||||||
const CART_STORAGE_KEY = 'ghatre_cart'
|
const CART_STORAGE_KEY = 'ghatre_cart'
|
||||||
|
|
||||||
@@ -93,6 +95,50 @@ export const useCartStore = defineStore('cart', () => {
|
|||||||
return cartItems.value.length
|
return cartItems.value.length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function syncProductsFromServer(serverProducts: Product[]): number {
|
||||||
|
const productMap = new Map(serverProducts.map(p => [p.id, p]))
|
||||||
|
const beforeCount = cartItems.value.length
|
||||||
|
|
||||||
|
cartItems.value = cartItems.value
|
||||||
|
.filter(item => productMap.has(item.product.id))
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
product: { ...productMap.get(item.product.id)! }
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (cartItems.value.length > 0 || beforeCount > 0) {
|
||||||
|
saveCartToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
return beforeCount - cartItems.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCartItemPrices() {
|
||||||
|
if (cartItems.value.length === 0) return
|
||||||
|
|
||||||
|
cartItems.value.forEach((item) => {
|
||||||
|
const buyPrice = getProductBuyPrice(item.product)
|
||||||
|
item.price = buyPrice
|
||||||
|
item.totalPrice = parseInt(String(buyPrice * item.quantity))
|
||||||
|
})
|
||||||
|
|
||||||
|
saveCartToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCartFromServer(): Promise<number> {
|
||||||
|
if (cartItems.value.length === 0) return 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverProducts: Product[] = await getProductsCatalog()
|
||||||
|
const removed = syncProductsFromServer(serverProducts)
|
||||||
|
updateCartItemPrices()
|
||||||
|
return removed
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error refreshing cart from server:', e)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cartItems,
|
cartItems,
|
||||||
addToCart,
|
addToCart,
|
||||||
@@ -102,7 +148,10 @@ export const useCartStore = defineStore('cart', () => {
|
|||||||
totalPrice,
|
totalPrice,
|
||||||
itemCount,
|
itemCount,
|
||||||
loadCartFromStorage,
|
loadCartFromStorage,
|
||||||
saveCartToStorage
|
saveCartToStorage,
|
||||||
|
syncProductsFromServer,
|
||||||
|
updateCartItemPrices,
|
||||||
|
refreshCartFromServer
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { getSettings } from '@/api/configuration'
|
||||||
|
|
||||||
|
export interface AppSetting {
|
||||||
|
id?: number
|
||||||
|
key?: string | number
|
||||||
|
title_fa?: string
|
||||||
|
value?: string | number
|
||||||
|
description?: string
|
||||||
|
is_active?: boolean
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
const settings = ref<AppSetting[]>([])
|
||||||
|
const loaded = ref(false)
|
||||||
|
let fetchPromise: Promise<AppSetting[]> | null = null
|
||||||
|
|
||||||
|
function getSettingByKey(key: string) {
|
||||||
|
const item = settings.value.find((setting) => setting?.key === key)
|
||||||
|
return item?.value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSettings(force = false) {
|
||||||
|
if (!force && fetchPromise) {
|
||||||
|
return fetchPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await getSettings()
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
settings.value = data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
} finally {
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings.value
|
||||||
|
})()
|
||||||
|
|
||||||
|
return fetchPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
loaded,
|
||||||
|
fetchSettings,
|
||||||
|
getSettingByKey,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import colors from './baseColor.json'
|
import colors from './baseColor.json'
|
||||||
/** base config **/
|
/** base config **/
|
||||||
/** api document **/
|
/** api document **/
|
||||||
export const baseURL: string = 'https://ghatreh.gold/server/'
|
export const baseURL: string = 'https://kh.kashkolgold.ir/server/'
|
||||||
// const baseUploadURL: string = '/site/file'
|
// const baseUploadURL: string = '/site/file'
|
||||||
// const uploadURL: string = baseURL + baseUploadURL
|
// const uploadURL: string = baseURL + baseUploadURL
|
||||||
// const baseURLStorage: string = baseURL + '/v1'
|
// const baseURLStorage: string = baseURL + '/v1'
|
||||||
const prefixCls: string = 'noname'
|
const prefixCls: string = 'noname'
|
||||||
// const version: string = pkg.version
|
// const version: string = pkg.version
|
||||||
// const tokenName: string = 'gholaktala_app_token'
|
// const tokenName: string = 'gholaktala_app_token'
|
||||||
const tokenName: string = 'ghatreh_app_token'
|
const tokenName: string = 'kashkolgold_app_token'
|
||||||
|
|
||||||
const baseButtonIconSize = '24'
|
const baseButtonIconSize = '24'
|
||||||
// const appName = { fa: 'بدون نام', en: 'noname' }
|
// const appName = { fa: 'بدون نام', en: 'noname' }
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { getStoreCategories } from '@/api/categories'
|
||||||
|
|
||||||
|
/** تبهای فیلتر محصول — فقط از API دستهبندی (هماهنگ با پنل ادمین) */
|
||||||
|
export async function loadProductCategoryTabNames(): Promise<string[]> {
|
||||||
|
const categories = await getStoreCategories()
|
||||||
|
return categories.map((item) => item.name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export function getConfigApp(key: string) {
|
||||||
|
const config = window.__APP_CONFIG__ ? window.__APP_CONFIG__.user : {}
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
return config[key]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getProjectDisplayName,
|
||||||
|
getDefaultLandingRoute,
|
||||||
|
hasHomePage,
|
||||||
|
hasAbshodeTrading,
|
||||||
|
hasProductShop,
|
||||||
|
isGhatreProject,
|
||||||
|
isKashkoolProject,
|
||||||
|
isBoursecuProject,
|
||||||
|
getProjectProfile,
|
||||||
|
getProjectProfileId,
|
||||||
|
} from '@/config/project-profile'
|
||||||
|
|
||||||
|
export {
|
||||||
|
getAppIconUrl,
|
||||||
|
getAppLogoUrl,
|
||||||
|
getAppLogoFullUrl,
|
||||||
|
resolveConfigAssetUrl,
|
||||||
|
} from '@/config/brand-assets'
|
||||||
|
|
||||||
|
export {
|
||||||
|
getAppPrimaryColor,
|
||||||
|
getAppSecondaryColor,
|
||||||
|
getAppTertiaryColor,
|
||||||
|
getAppBackgroundColor,
|
||||||
|
applyBrandTheme,
|
||||||
|
} from '@/config/brand-theme'
|
||||||
@@ -30,11 +30,11 @@ export async function authenticateWithFingerprint() {
|
|||||||
|
|
||||||
const createOptions = {
|
const createOptions = {
|
||||||
challenge,
|
challenge,
|
||||||
rp: { name: 'ghatreh' },
|
rp: { name: 'kashkol' },
|
||||||
user: {
|
user: {
|
||||||
id: Uint8Array.from('user-id', c => c.charCodeAt(0)),
|
id: Uint8Array.from('user-id', c => c.charCodeAt(0)),
|
||||||
name: 'Ghatreh',
|
name: 'Kashkol',
|
||||||
displayName: 'Ghatreh'
|
displayName: 'Kashkol'
|
||||||
},
|
},
|
||||||
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
@@ -52,7 +52,7 @@ export async function authenticateWithFingerprint() {
|
|||||||
const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
|
const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
|
||||||
|
|
||||||
// بازیابی توکن از localStorage
|
// بازیابی توکن از localStorage
|
||||||
const token = localStorage.getItem('ghatreh_app_token')
|
const token = localStorage.getItem('kashkol_app_token')
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.warn('توکن پیدا نشد، عملیات متوقف شد.')
|
console.warn('توکن پیدا نشد، عملیات متوقف شد.')
|
||||||
|
|||||||
@@ -32,9 +32,23 @@ import { computed, ref } from 'vue'
|
|||||||
import type { Rule } from 'ant-design-vue/es/form'
|
import type { Rule } from 'ant-design-vue/es/form'
|
||||||
import { useWalletStore } from '@/store/walletStore'
|
import { useWalletStore } from '@/store/walletStore'
|
||||||
import {isToken} from "@/utils/index";
|
import {isToken} from "@/utils/index";
|
||||||
|
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
||||||
|
|
||||||
export const mobileValidation = (title?: string) => {
|
export const mobileValidation = (title?: string): Rule => {
|
||||||
return { len: 11, message: `شماره همراه باید 11 رقم باشد` }
|
const label = title ?? 'شماره تلفن'
|
||||||
|
return {
|
||||||
|
validator: (_rule, value) => {
|
||||||
|
const normalized = persianToEnglishDigits(String(value ?? '').trim())
|
||||||
|
if (!normalized) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (!/^09\d{9}$/.test(normalized)) {
|
||||||
|
return Promise.reject(`${label} باید با 09 شروع شود و 11 رقم باشد`)
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
trigger: ['change', 'blur'],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const required = (title?: string) => {
|
export const required = (title?: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||||
|
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'
|
||||||
|
|
||||||
|
/** تبدیل ارقام فارسی/عربی و جداکننده اعشار به فرمت انگلیسی */
|
||||||
|
export function persianToEnglishDigits(value: string | number | null | undefined): string {
|
||||||
|
if (value == null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
.replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d)))
|
||||||
|
.replace(/٫/g, '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldNormalizeInputDigits(options: {
|
||||||
|
isPassword?: boolean
|
||||||
|
type?: string
|
||||||
|
inputmode?: string
|
||||||
|
}): boolean {
|
||||||
|
if (options.isPassword) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (options.type === 'price' || options.type === 'number') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const mode = String(options.inputmode ?? '').trim().toLowerCase()
|
||||||
|
return mode === 'numeric' || mode === 'decimal' || mode === 'number'
|
||||||
|
}
|
||||||
@@ -1,86 +1,185 @@
|
|||||||
import {loading} from '@/utils/index'
|
import { getLastPrice, getPrice } from '@/api/home'
|
||||||
import {getLastPrice, getPrice} from '@/api/home'
|
import type { Price, PriceUp } from '@/models/home'
|
||||||
import {ref} from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import type {Price} from '@/models/home'
|
import {
|
||||||
|
priceSocketConnected,
|
||||||
|
priceStreamLifecycle,
|
||||||
|
priceWssEnabled,
|
||||||
|
shouldUsePricePolling,
|
||||||
|
} from '@/utils/priceSocketState'
|
||||||
|
|
||||||
|
const LAST_INTERVAL_MS = 10000
|
||||||
|
|
||||||
|
let lastInterval: ReturnType<typeof setInterval> | undefined
|
||||||
|
let lastSubscribers = 0
|
||||||
|
|
||||||
let interval: any
|
|
||||||
export const price = ref<Price>()
|
export const price = ref<Price>()
|
||||||
export const lastPrice = ref<Price>()
|
export const lastPrice = ref<Price>()
|
||||||
export const olderPrice = ref<number| undefined>(0)
|
export const olderPrice = ref<number | undefined>(0)
|
||||||
|
|
||||||
|
let lastPriceSourceAt = 0
|
||||||
|
let consecutivePriceFetchFailures = 0
|
||||||
|
|
||||||
|
/** buy/sell نمایش و معامله — همان buy/sell/price_last از /api/price/last */
|
||||||
|
export function getTradeBuyPrice(p?: Price | null) {
|
||||||
|
return p?.buy ?? p?.price_last ?? p?.up?.buy ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTradeSellPrice(p?: Price | null) {
|
||||||
|
return p?.sell ?? p?.up?.sell ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** up جدا نگه داشته میشود؛ buy/sell/price_last از lastPrice سرور دستنخورده میماند */
|
||||||
|
export function normalizePriceResponse(data: Price): Price {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidPricePayload(data?: Price | null): boolean {
|
||||||
|
if (!data) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
getTradeBuyPrice(data) > 0
|
||||||
|
|| getTradeSellPrice(data) > 0
|
||||||
|
|| Number(data.price_last ?? data.price ?? 0) > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPriceSourceReceived() {
|
||||||
|
lastPriceSourceAt = Date.now()
|
||||||
|
consecutivePriceFetchFailures = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPriceSourceFetchFailed() {
|
||||||
|
consecutivePriceFetchFailures += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean {
|
||||||
|
return lastPriceSourceAt > 0 && Date.now() - lastPriceSourceAt < maxAgeMs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consecutivePriceSourceFailures(): number {
|
||||||
|
return consecutivePriceFetchFailures
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPollingInterval() {
|
||||||
|
if (lastInterval || !shouldUsePricePolling()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastInterval = setInterval(() => {
|
||||||
|
if (shouldUsePricePolling()) {
|
||||||
|
void getLastPrices()
|
||||||
|
}
|
||||||
|
}, LAST_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPollingInterval() {
|
||||||
|
if (lastInterval) {
|
||||||
|
clearInterval(lastInterval)
|
||||||
|
lastInterval = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLastPricePolling() {
|
||||||
|
const usePolling = shouldUsePricePolling()
|
||||||
|
if (usePolling) {
|
||||||
|
if (lastSubscribers > 0) {
|
||||||
|
startPollingInterval()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPollingInterval()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [priceWssEnabled.value, priceSocketConnected.value, priceStreamLifecycle.value] as const,
|
||||||
|
() => {
|
||||||
|
syncLastPricePolling()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export function startLastPricePolling() {
|
||||||
|
lastSubscribers++
|
||||||
|
syncLastPricePolling()
|
||||||
|
}
|
||||||
|
|
||||||
export async function setIntervalGetAllPrice() {
|
export async function setIntervalGetAllPrice() {
|
||||||
// console.log('salam')
|
startLastPricePolling()
|
||||||
try {
|
|
||||||
interval = setInterval(async () => {
|
|
||||||
await getLastPrices()
|
|
||||||
await getUpPrice()
|
|
||||||
}, 10000)
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setIntervalGetLastPrice() {
|
export async function setIntervalGetLastPrice() {
|
||||||
// console.log('salam')
|
startLastPricePolling()
|
||||||
try {
|
|
||||||
interval = setInterval(async () => {
|
|
||||||
await getLastPrices()
|
|
||||||
// await getUpPrice()
|
|
||||||
}, 10000)
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUpPrice() {
|
export function stopLastPricePolling() {
|
||||||
try {
|
lastSubscribers = Math.max(0, lastSubscribers - 1)
|
||||||
// loading.value = true
|
if (lastSubscribers === 0) {
|
||||||
const data = await getPrice()
|
stopPollingInterval()
|
||||||
// const dataLast = await getLastPrice()
|
}
|
||||||
// if (lastPrice.value?.price_last != dataLast.price_last) {
|
|
||||||
// olderPrice.value = lastPrice.value?.price_last
|
|
||||||
// }
|
|
||||||
price.value = data
|
|
||||||
// lastPrice.value = dataLast
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
} finally {
|
|
||||||
// loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function getLastPrices() {
|
|
||||||
try {
|
|
||||||
// loading.value = true
|
|
||||||
// const data = await getPrice()
|
|
||||||
const dataLast = await getLastPrice()
|
|
||||||
if (lastPrice.value?.price_last != dataLast.price_last) {
|
|
||||||
olderPrice.value = lastPrice.value?.price_last
|
|
||||||
}
|
|
||||||
// price.value = data
|
|
||||||
lastPrice.value = dataLast
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
} finally {
|
|
||||||
// loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopPriceInterval() {
|
export function stopPriceInterval() {
|
||||||
// console.log('stop')
|
stopLastPricePolling()
|
||||||
clearInterval(interval)
|
}
|
||||||
|
|
||||||
|
async function fetchUpPrices(): Promise<PriceUp | undefined> {
|
||||||
|
try {
|
||||||
|
const up = await getPrice()
|
||||||
|
if (up?.buy != null && up?.sell != null) {
|
||||||
|
return { buy: up.buy, sell: up.sell }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUpPrice() {
|
||||||
|
await getLastPrices()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLastPrices(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let dataLast = await getLastPrice()
|
||||||
|
|
||||||
|
if (!dataLast?.up) {
|
||||||
|
const up = await fetchUpPrices()
|
||||||
|
if (up) {
|
||||||
|
dataLast = { ...dataLast, up }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLastPricePayload(dataLast)
|
||||||
|
return hasValidPricePayload(dataLast)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
markPriceSourceFetchFailed()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyLastPricePayload(dataLast: Price) {
|
||||||
|
const normalized = normalizePriceResponse(dataLast)
|
||||||
|
if (lastPrice.value?.price_last != normalized.price_last) {
|
||||||
|
olderPrice.value = lastPrice.value?.price_last
|
||||||
|
}
|
||||||
|
lastPrice.value = normalized
|
||||||
|
price.value = {
|
||||||
|
...normalized,
|
||||||
|
buy: getTradeBuyPrice(normalized),
|
||||||
|
sell: getTradeSellPrice(normalized),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasValidPricePayload(normalized)) {
|
||||||
|
markPriceSourceReceived()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function percentPriceChanged() {
|
export function percentPriceChanged() {
|
||||||
// console.log(olderPrice.value)
|
if (!olderPrice.value) {
|
||||||
// console.log(lastPrice.value?.price_last)
|
return 0
|
||||||
if (!olderPrice.value)
|
}
|
||||||
return 0
|
return ((lastPrice.value?.price_last! - olderPrice.value) * 100) / olderPrice.value
|
||||||
return ((lastPrice.value?.price_last-olderPrice.value) *100)/olderPrice.value
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,628 @@
|
|||||||
|
import type { Price } from '@/models/home'
|
||||||
|
import type { Product } from '@/models/products'
|
||||||
|
import { Modal } from 'ant-design-vue'
|
||||||
|
import { getConfigApp } from '@/utils/config'
|
||||||
|
import { applyProductsListFromStream } from '@/utils/productStreamBridge'
|
||||||
|
import {
|
||||||
|
priceSocketConnected,
|
||||||
|
priceStreamConnectingSince,
|
||||||
|
priceStreamLifecycle,
|
||||||
|
priceWssEnabled,
|
||||||
|
} from '@/utils/priceSocketState'
|
||||||
|
|
||||||
|
export {
|
||||||
|
priceSocketConnected,
|
||||||
|
priceStreamConnectingSince,
|
||||||
|
priceStreamLifecycle,
|
||||||
|
priceWssEnabled,
|
||||||
|
shouldUsePricePolling,
|
||||||
|
} from '@/utils/priceSocketState'
|
||||||
|
|
||||||
|
const RECONNECT_BASE_MS = 3000
|
||||||
|
const RECONNECT_MAX_MS = 30000
|
||||||
|
const FAILURES_BEFORE_ALERT = 4
|
||||||
|
const ALERT_COOLDOWN_MS = 5 * 60 * 1000
|
||||||
|
const FALLBACK_POLL_INTERVAL_MS = 10000
|
||||||
|
const PRICE_SOURCE_STALE_MS_MOBILE = 50000
|
||||||
|
const PRICE_SOURCE_STALE_MS_DESKTOP = 35000
|
||||||
|
const STALE_STREAM_MS = 120000
|
||||||
|
const FALLBACK_POLL_DELAY_MS = 2500
|
||||||
|
|
||||||
|
let eventSource: EventSource | null = null
|
||||||
|
let subscribers = 0
|
||||||
|
let stopped = false
|
||||||
|
let getSetting: ((key: string) => unknown) | null = null
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let staleCheckTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
let reconnectAttempt = 0
|
||||||
|
let lastStreamEventAt = 0
|
||||||
|
let networkAlertOpen = false
|
||||||
|
let lastNetworkAlertAt = 0
|
||||||
|
let listenersBound = false
|
||||||
|
let fallbackPollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let fallbackPollInterval: ReturnType<typeof setInterval> | undefined
|
||||||
|
let pendingPriceAlert = false
|
||||||
|
|
||||||
|
function isMobileDevice(): boolean {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return /iPhone|iPad|iPod|Android|Mobile/i.test(navigator.userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPageVisible(): boolean {
|
||||||
|
return typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||||
|
}
|
||||||
|
|
||||||
|
function markConnectingLifecycle() {
|
||||||
|
priceStreamLifecycle.value = 'connecting'
|
||||||
|
if (priceStreamConnectingSince.value <= 0) {
|
||||||
|
priceStreamConnectingSince.value = Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConnectingLifecycle() {
|
||||||
|
priceStreamConnectingSince.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthySetting(value: unknown, defaultValue = true): boolean {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['true', '1', 'yes', 'on'].includes(String(value).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStreamPath(): string {
|
||||||
|
const ssePath = String(getSetting?.('sse_path') ?? '').trim()
|
||||||
|
if (ssePath && !ssePath.includes('/prc/')) {
|
||||||
|
return ssePath.startsWith('/') ? ssePath : `/${ssePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyPath = String(getSetting?.('wss_path') ?? '').trim()
|
||||||
|
if (legacyPath && !legacyPath.includes('/prc/')) {
|
||||||
|
return legacyPath.startsWith('/') ? legacyPath : `/${legacyPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/api/price/stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStreamUrl(): string | null {
|
||||||
|
if (!getSetting) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTruthySetting(getSetting('wss_enabled'), true)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '')
|
||||||
|
return `${base}${resolveStreamPath()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReconnectTimer() {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStaleCheck() {
|
||||||
|
if (staleCheckTimer) {
|
||||||
|
clearInterval(staleCheckTimer)
|
||||||
|
staleCheckTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchStreamActivity() {
|
||||||
|
lastStreamEventAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStaleCheck() {
|
||||||
|
stopStaleCheck()
|
||||||
|
touchStreamActivity()
|
||||||
|
staleCheckTimer = setInterval(() => {
|
||||||
|
if (
|
||||||
|
stopped
|
||||||
|
|| subscribers <= 0
|
||||||
|
|| !eventSource
|
||||||
|
|| eventSource.readyState !== EventSource.OPEN
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - lastStreamEventAt > STALE_STREAM_MS) {
|
||||||
|
console.warn('Price SSE stale — reconnecting')
|
||||||
|
handleStreamLost(true)
|
||||||
|
}
|
||||||
|
}, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFallbackPollTimer() {
|
||||||
|
if (fallbackPollTimer) {
|
||||||
|
clearTimeout(fallbackPollTimer)
|
||||||
|
fallbackPollTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopFallbackPolling() {
|
||||||
|
clearFallbackPollTimer()
|
||||||
|
if (fallbackPollInterval) {
|
||||||
|
clearInterval(fallbackPollInterval)
|
||||||
|
fallbackPollInterval = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFallbackPolling() {
|
||||||
|
if (fallbackPollInterval) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleFallbackPoll()
|
||||||
|
fallbackPollInterval = setInterval(() => {
|
||||||
|
if (stopped || subscribers <= 0 || priceSocketConnected.value) {
|
||||||
|
stopFallbackPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void runFallbackPoll()
|
||||||
|
}, FALLBACK_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFallbackPoll() {
|
||||||
|
clearFallbackPollTimer()
|
||||||
|
fallbackPollTimer = setTimeout(() => {
|
||||||
|
fallbackPollTimer = undefined
|
||||||
|
if (!stopped && subscribers > 0 && !priceSocketConnected.value) {
|
||||||
|
void runFallbackPoll()
|
||||||
|
}
|
||||||
|
}, FALLBACK_POLL_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFallbackPoll() {
|
||||||
|
if (!priceWssEnabled.value || priceSocketConnected.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { getLastPrices, hasRecentPriceFromSource, markPriceSourceFetchFailed } = await import(
|
||||||
|
'@/utils/price'
|
||||||
|
)
|
||||||
|
const { refreshProductsPageData } = await import(
|
||||||
|
'@/views/base/products/composables/useProductsPageData'
|
||||||
|
)
|
||||||
|
|
||||||
|
const [lastResult] = await Promise.allSettled([
|
||||||
|
getLastPrices(),
|
||||||
|
refreshProductsPageData(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const priceOk =
|
||||||
|
lastResult.status === 'fulfilled'
|
||||||
|
&& lastResult.value === true
|
||||||
|
&& hasRecentPriceFromSource(getPriceSourceStaleMs())
|
||||||
|
|
||||||
|
if (!priceOk) {
|
||||||
|
markPriceSourceFetchFailed()
|
||||||
|
}
|
||||||
|
|
||||||
|
void maybeShowNetworkAlert()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Price fallback poll failed', error)
|
||||||
|
const { markPriceSourceFetchFailed } = await import('@/utils/price')
|
||||||
|
markPriceSourceFetchFailed()
|
||||||
|
void maybeShowNetworkAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriceSourceStaleMs(): number {
|
||||||
|
return isMobileDevice() ? PRICE_SOURCE_STALE_MS_MOBILE : PRICE_SOURCE_STALE_MS_DESKTOP
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isRealPriceOutage(): Promise<boolean> {
|
||||||
|
const {
|
||||||
|
hasRecentPriceFromSource,
|
||||||
|
consecutivePriceSourceFailures,
|
||||||
|
} = await import('@/utils/price')
|
||||||
|
|
||||||
|
const staleMs = getPriceSourceStaleMs()
|
||||||
|
|
||||||
|
if (hasRecentPriceFromSource(staleMs)) {
|
||||||
|
pendingPriceAlert = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFailures = consecutivePriceSourceFailures()
|
||||||
|
|
||||||
|
if (fetchFailures >= 2) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!priceSocketConnected.value && fetchFailures >= 1 && reconnectAttempt >= 2) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMobileDevice() && !navigator.onLine && fetchFailures >= 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMobileDevice() && reconnectAttempt >= FAILURES_BEFORE_ALERT) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNetworkAlert() {
|
||||||
|
if (networkAlertOpen || stopped || !priceWssEnabled.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPageVisible()) {
|
||||||
|
pendingPriceAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastNetworkAlertAt < ALERT_COOLDOWN_MS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastNetworkAlertAt = now
|
||||||
|
pendingPriceAlert = false
|
||||||
|
networkAlertOpen = true
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'اتصال قیمت برقرار نیست',
|
||||||
|
content:
|
||||||
|
'بهروزرسانی لحظهای قیمت قطع شده است. اتصال اینترنت را بررسی کنید یا دوباره تلاش کنید. تا زمان اتصال مجدد، قیمت با تأخیر بهروز میشود.',
|
||||||
|
okText: 'تلاش مجدد',
|
||||||
|
cancelText: 'بستن',
|
||||||
|
centered: true,
|
||||||
|
onOk: () => {
|
||||||
|
networkAlertOpen = false
|
||||||
|
reconnectAttempt = 0
|
||||||
|
forceReconnect()
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
networkAlertOpen = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeShowNetworkAlert() {
|
||||||
|
if (!(await isRealPriceOutage())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showNetworkAlert()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (stopped || subscribers <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReconnectTimer()
|
||||||
|
const delay = Math.min(
|
||||||
|
RECONNECT_BASE_MS * Math.pow(2, reconnectAttempt),
|
||||||
|
RECONNECT_MAX_MS,
|
||||||
|
)
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectAttempt += 1
|
||||||
|
connect()
|
||||||
|
maybeShowNetworkAlert()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLastFromStream(last: Price) {
|
||||||
|
const { applyLastPricePayload } = await import('@/utils/price')
|
||||||
|
applyLastPricePayload(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLastPayload(payload: unknown) {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = payload as Price
|
||||||
|
if (last.price_last == null && last.price == null && last.buy == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
touchStreamActivity()
|
||||||
|
void applyLastFromStream(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleListPayload(payload: unknown) {
|
||||||
|
if (!Array.isArray(payload) || !payload.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
touchStreamActivity()
|
||||||
|
applyProductsListFromStream(payload as Product[])
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindStreamEvents(source: EventSource) {
|
||||||
|
source.addEventListener('last', (event) => {
|
||||||
|
try {
|
||||||
|
handleLastPayload(JSON.parse(event.data))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Price SSE last parse failed', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
source.addEventListener('list', (event) => {
|
||||||
|
try {
|
||||||
|
handleListPayload(JSON.parse(event.data))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Price SSE list parse failed', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
source.addEventListener('ping', () => {
|
||||||
|
touchStreamActivity()
|
||||||
|
})
|
||||||
|
|
||||||
|
source.onmessage = () => {
|
||||||
|
touchStreamActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStreamLost(scheduleRetry = true) {
|
||||||
|
const wasConnected = priceSocketConnected.value
|
||||||
|
disconnectSourceOnly()
|
||||||
|
priceSocketConnected.value = false
|
||||||
|
priceStreamLifecycle.value = scheduleRetry ? 'connecting' : 'closed'
|
||||||
|
|
||||||
|
if ((wasConnected || scheduleRetry) && scheduleRetry) {
|
||||||
|
startFallbackPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleRetry && !stopped && subscribers > 0) {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectSourceOnly() {
|
||||||
|
stopStaleCheck()
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.onopen = null
|
||||||
|
eventSource.onerror = null
|
||||||
|
eventSource.onmessage = null
|
||||||
|
eventSource.close()
|
||||||
|
eventSource = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (stopped || subscribers <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = resolveStreamUrl()
|
||||||
|
if (!url) {
|
||||||
|
priceSocketConnected.value = false
|
||||||
|
priceStreamLifecycle.value = 'closed'
|
||||||
|
clearConnectingLifecycle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventSource) {
|
||||||
|
const state = eventSource.readyState
|
||||||
|
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disconnectSourceOnly()
|
||||||
|
}
|
||||||
|
|
||||||
|
markConnectingLifecycle()
|
||||||
|
|
||||||
|
try {
|
||||||
|
eventSource = new EventSource(url)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Price SSE connect failed', error)
|
||||||
|
handleStreamLost()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bindStreamEvents(eventSource)
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
clearReconnectTimer()
|
||||||
|
stopFallbackPolling()
|
||||||
|
networkAlertOpen = false
|
||||||
|
priceStreamLifecycle.value = 'open'
|
||||||
|
clearConnectingLifecycle()
|
||||||
|
priceSocketConnected.value = true
|
||||||
|
touchStreamActivity()
|
||||||
|
startStaleCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
const state = eventSource?.readyState
|
||||||
|
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStreamLost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
clearReconnectTimer()
|
||||||
|
stopFallbackPolling()
|
||||||
|
disconnectSourceOnly()
|
||||||
|
priceSocketConnected.value = false
|
||||||
|
priceStreamLifecycle.value = 'idle'
|
||||||
|
clearConnectingLifecycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forceReconnect() {
|
||||||
|
if (stopped || subscribers <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReconnectTimer()
|
||||||
|
disconnect()
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBrowserOnline() {
|
||||||
|
if (stopped || subscribers <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempt = 0
|
||||||
|
networkAlertOpen = false
|
||||||
|
forceReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBrowserOffline() {
|
||||||
|
handleStreamLost(true)
|
||||||
|
void maybeShowNetworkAlert()
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindBrowserNetworkListeners() {
|
||||||
|
if (listenersBound || typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listenersBound = true
|
||||||
|
window.addEventListener('online', onBrowserOnline)
|
||||||
|
window.addEventListener('offline', onBrowserOffline)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindBrowserNetworkListeners() {
|
||||||
|
if (!listenersBound || typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listenersBound = false
|
||||||
|
window.removeEventListener('online', onBrowserOnline)
|
||||||
|
window.removeEventListener('offline', onBrowserOffline)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPriceSocket(settingGetter: (key: string) => unknown) {
|
||||||
|
getSetting = settingGetter
|
||||||
|
subscribers += 1
|
||||||
|
stopped = false
|
||||||
|
connect()
|
||||||
|
bindBrowserNetworkListeners()
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('pageshow', onPageShow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopPriceSocket() {
|
||||||
|
subscribers = Math.max(0, subscribers - 1)
|
||||||
|
if (subscribers > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopped = true
|
||||||
|
disconnect()
|
||||||
|
unbindBrowserNetworkListeners()
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('pageshow', onPageShow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (stopped || subscribers <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempt = 0
|
||||||
|
networkAlertOpen = false
|
||||||
|
|
||||||
|
if (pendingPriceAlert) {
|
||||||
|
void maybeShowNetworkAlert()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
|
||||||
|
forceReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageShow(event: PageTransitionEvent) {
|
||||||
|
if (stopped || subscribers <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
networkAlertOpen = false
|
||||||
|
forceReconnect()
|
||||||
|
if (pendingPriceAlert) {
|
||||||
|
void maybeShowNetworkAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPriceWebSocketEnabled(settingGetter: (key: string) => unknown): boolean {
|
||||||
|
getSetting = settingGetter
|
||||||
|
if (!isTruthySetting(settingGetter('wss_enabled'), true)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveStreamUrl() !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
let channelStarted = false
|
||||||
|
|
||||||
|
function shutdownPriceRealtimeChannel() {
|
||||||
|
if (!channelStarted && subscribers <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channelStarted = false
|
||||||
|
stopped = true
|
||||||
|
subscribers = 0
|
||||||
|
disconnect()
|
||||||
|
unbindBrowserNetworkListeners()
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('pageshow', onPageShow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initPriceRealtimeChannel(settingGetter: (key: string) => unknown) {
|
||||||
|
getSetting = settingGetter
|
||||||
|
priceWssEnabled.value = isTruthySetting(settingGetter('wss_enabled'), true)
|
||||||
|
|
||||||
|
if (!priceWssEnabled.value || !isPriceWebSocketEnabled(settingGetter)) {
|
||||||
|
shutdownPriceRealtimeChannel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelStarted && subscribers > 0) {
|
||||||
|
forceReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channelStarted = true
|
||||||
|
stopped = false
|
||||||
|
startPriceSocket(settingGetter)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const priceSocketConnected = ref(false)
|
||||||
|
export const priceWssEnabled = ref(false)
|
||||||
|
|
||||||
|
export type PriceStreamLifecycle = 'idle' | 'connecting' | 'open' | 'closed'
|
||||||
|
|
||||||
|
export const priceStreamLifecycle = ref<PriceStreamLifecycle>('idle')
|
||||||
|
|
||||||
|
export const priceStreamConnectingSince = ref(0)
|
||||||
|
|
||||||
|
const CONNECTING_POLL_AFTER_MS = 8000
|
||||||
|
|
||||||
|
export function shouldUsePricePolling(): boolean {
|
||||||
|
if (!priceWssEnabled.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceStreamLifecycle.value === 'open') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceStreamLifecycle.value === 'connecting') {
|
||||||
|
const since = priceStreamConnectingSince.value
|
||||||
|
if (since > 0 && Date.now() - since >= CONNECTING_POLL_AFTER_MS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||