Port modular price, product, and PWA fixes into boursecu
Sync live quote handling, SSE product images, store hours, file URL normalization, PWA icon cache, TV monitor, about software, SuperAdmin menu gates, price alerts, and related UX from modular while preserving boursecu branding and config constraints. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1 +1,15 @@
|
|||||||
|
# کپی به `.env.production` / `.env.development`
|
||||||
|
|
||||||
|
# لوکال (پیشنهادی): پروکسی Vite → بدون CORS
|
||||||
|
# VITE_SUPERADMIN_URL=/__sa
|
||||||
|
# VITE_TENANT_DOMAIN=k.montzar.ir
|
||||||
|
|
||||||
|
# لوکال با API سوپرادمین روی سیستم خودت:
|
||||||
|
# VITE_SUPERADMIN_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# پروداکشن:
|
||||||
|
# VITE_SUPERADMIN_URL=https://adm.montzar.ir
|
||||||
|
# failover: adm.montzar.ir → adm.montech.ir → adm.montest.ir
|
||||||
|
|
||||||
|
# URL لندینگ داخل اپ
|
||||||
VITE_LANDING_APP_URL=/landing/
|
VITE_LANDING_APP_URL=/landing/
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ dist-ssr
|
|||||||
coverage
|
coverage
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Local env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
/cypress/videos/
|
/cypress/videos/
|
||||||
/cypress/screenshots/
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
|||||||
Vendored
+4
@@ -51,13 +51,16 @@ declare module 'vue' {
|
|||||||
Disabled: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/disabled.vue')['default']
|
Disabled: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/disabled.vue')['default']
|
||||||
DisabledDate: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/disabled-date.vue')['default']
|
DisabledDate: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/disabled-date.vue')['default']
|
||||||
Empty: typeof import('./src/components/empty.vue')['default']
|
Empty: typeof import('./src/components/empty.vue')['default']
|
||||||
|
EnamadBadge: typeof import('./src/components/EnamadBadge.vue')['default']
|
||||||
ExtraFooter: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/extra-footer.vue')['default']
|
ExtraFooter: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/extra-footer.vue')['default']
|
||||||
Format: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/format.vue')['default']
|
Format: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/format.vue')['default']
|
||||||
|
GoldDayCalculatorModal: typeof import('./src/components/GoldDayCalculatorModal.vue')['default']
|
||||||
GoldLoading: typeof import('./src/components/GoldLoading.vue')['default']
|
GoldLoading: typeof import('./src/components/GoldLoading.vue')['default']
|
||||||
InstallButton: typeof import('./src/components/InstallButton.vue')['default']
|
InstallButton: typeof import('./src/components/InstallButton.vue')['default']
|
||||||
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']
|
||||||
ProfileAvatarUpload: typeof import('./src/components/ProfileAvatarUpload.vue')['default']
|
ProfileAvatarUpload: typeof import('./src/components/ProfileAvatarUpload.vue')['default']
|
||||||
PwaInstallBanner: typeof import('./src/components/pwa/PwaInstallBanner.vue')['default']
|
PwaInstallBanner: typeof import('./src/components/pwa/PwaInstallBanner.vue')['default']
|
||||||
PwaInstallGuideModal: typeof import('./src/components/pwa/PwaInstallGuideModal.vue')['default']
|
PwaInstallGuideModal: typeof import('./src/components/pwa/PwaInstallGuideModal.vue')['default']
|
||||||
@@ -76,6 +79,7 @@ declare module 'vue' {
|
|||||||
Switchable: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/switchable.vue')['default']
|
Switchable: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/switchable.vue')['default']
|
||||||
Text: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/text.vue')['default']
|
Text: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/text.vue')['default']
|
||||||
Time: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/time.vue')['default']
|
Time: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/time.vue')['default']
|
||||||
|
TradeFeeConfirmSummary: typeof import('./src/components/TradeFeeConfirmSummary.vue')['default']
|
||||||
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
|
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
|
||||||
VanActionSheet: typeof import('vant/es')['ActionSheet']
|
VanActionSheet: typeof import('vant/es')['ActionSheet']
|
||||||
VanDialog: typeof import('vant/es')['Dialog']
|
VanDialog: typeof import('vant/es')['Dialog']
|
||||||
|
|||||||
+103
-20
@@ -2,6 +2,65 @@
|
|||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var icon = ''
|
||||||
|
try {
|
||||||
|
icon = localStorage.getItem('PWA_INSTALL_ICON_URL') || ''
|
||||||
|
} catch (e) {}
|
||||||
|
var title = ''
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem('PWA_MANIFEST_CACHE_V5')
|
||||||
|
if (raw) {
|
||||||
|
var payload = JSON.parse(raw)
|
||||||
|
var manifest = payload && payload.manifest
|
||||||
|
if (manifest) {
|
||||||
|
title = manifest.short_name || manifest.name || ''
|
||||||
|
if (!icon && manifest.icons && manifest.icons.length) {
|
||||||
|
var icons = manifest.icons
|
||||||
|
var pick = function (size) {
|
||||||
|
var target = size + 'x' + size
|
||||||
|
for (var i = 0; i < icons.length; i++) {
|
||||||
|
if (String(icons[i].sizes || '').indexOf(target) !== -1 && icons[i].src) {
|
||||||
|
return icons[i].src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
icon = pick(180) || pick(192) || pick(512) || (icons[0] && icons[0].src) || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (title) {
|
||||||
|
document.title = title
|
||||||
|
var appleTitle = document.createElement('meta')
|
||||||
|
appleTitle.name = 'apple-mobile-web-app-title'
|
||||||
|
appleTitle.content = title
|
||||||
|
document.head.appendChild(appleTitle)
|
||||||
|
}
|
||||||
|
if (icon) {
|
||||||
|
var sized = document.createElement('link')
|
||||||
|
sized.rel = 'apple-touch-icon'
|
||||||
|
sized.setAttribute('data-dynamic-pwa', 'true')
|
||||||
|
sized.setAttribute('data-pwa-icon-src', icon)
|
||||||
|
sized.setAttribute('sizes', '180x180')
|
||||||
|
sized.href = icon
|
||||||
|
document.head.appendChild(sized)
|
||||||
|
var primary = document.createElement('link')
|
||||||
|
primary.rel = 'apple-touch-icon'
|
||||||
|
primary.setAttribute('data-dynamic-pwa', 'true')
|
||||||
|
primary.setAttribute('data-pwa-icon-src', icon)
|
||||||
|
primary.href = icon
|
||||||
|
document.head.appendChild(primary)
|
||||||
|
var img = new Image()
|
||||||
|
img.decoding = 'async'
|
||||||
|
img.src = icon
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
<meta name="enamad" content="44889195" />
|
<meta name="enamad" content="44889195" />
|
||||||
|
|
||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
@@ -343,30 +402,49 @@
|
|||||||
return window.location.protocol === 'https:' ? 'https:' : 'http:'
|
return window.location.protocol === 'https:' ? 'https:' : 'http:'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVersionUrls() {
|
function resolveTenantDomain() {
|
||||||
var protocol = getProtocol()
|
var host = String(window.location.hostname || '').trim()
|
||||||
|
if (!host || host === 'localhost' || host === '127.0.0.1') {
|
||||||
|
return 'k.montzar.ir'
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSuperAdminBases() {
|
||||||
return [
|
return [
|
||||||
'https://nerkh.gold/get_version',
|
'https://adm.montzar.ir',
|
||||||
'https://tablo.talasevin.ir/get_version',
|
'https://adm.montech.ir',
|
||||||
'https://abshode-service.montgit.ir/server/get_version',
|
'https://adm.montest.ir'
|
||||||
protocol + '//212.23.201.173:8381/get_version',
|
|
||||||
protocol + '//91.212.174.137:4040/server/get_version'
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVersionUrls() {
|
||||||
|
var domain = encodeURIComponent(resolveTenantDomain())
|
||||||
|
return getSuperAdminBases().map(function (base) {
|
||||||
|
return base + '/get_version?domain=' + domain
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getConfigUrls() {
|
function getConfigUrls() {
|
||||||
var protocol = getProtocol()
|
var domain = encodeURIComponent(resolveTenantDomain())
|
||||||
|
return getSuperAdminBases().map(function (base) {
|
||||||
return [
|
return base + '/get_user?domain=' + domain
|
||||||
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'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// پاککردن URL موفقیت قدیمی رجیستری (مثل nerkh.gold)
|
||||||
|
;(function clearLegacyConfigSuccess() {
|
||||||
|
var last = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
if (!last) return
|
||||||
|
var ok = getSuperAdminBases().some(function (base) {
|
||||||
|
return last.indexOf(base) === 0
|
||||||
|
})
|
||||||
|
if (!ok) {
|
||||||
|
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
document.cookie = CONFIG_COOKIE_NAME + '=; Path=/; Max-Age=0; SameSite=Lax'
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
function sortVersionUrls(urls) {
|
function sortVersionUrls(urls) {
|
||||||
var lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
|
var lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
|
||||||
|
|
||||||
@@ -382,6 +460,11 @@
|
|||||||
function sortByLastSuccess(urls) {
|
function sortByLastSuccess(urls) {
|
||||||
var lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
var lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
|
||||||
|
if (lastSuccessUrl && urls.indexOf(lastSuccessUrl) === -1) {
|
||||||
|
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
lastSuccessUrl = null
|
||||||
|
}
|
||||||
|
|
||||||
return urls.slice().sort(function (a, b) {
|
return urls.slice().sort(function (a, b) {
|
||||||
if (a === lastSuccessUrl) return -1
|
if (a === lastSuccessUrl) return -1
|
||||||
if (b === lastSuccessUrl) return 1
|
if (b === lastSuccessUrl) return 1
|
||||||
@@ -665,10 +748,10 @@
|
|||||||
'<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>' +
|
'<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>' +
|
||||||
'<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در بارگذاری برنامه</h2>' +
|
'<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 style="margin: 0 0 20px; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
|
||||||
'ارتباط با سرور برقرار نشد یا فایلهای برنامه بهدرستی دریافت نشدند.' +
|
'برقراری ارتباط با سرور امکانپذیر نیست. لطفاً اتصال اینترنت خود را بررسی کرده و صفحه را تازهسازی نمایید.' +
|
||||||
'</p>' +
|
'</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 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>' +
|
||||||
'<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 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;">' +
|
||||||
'پاکسازی و تلاش مجدد' +
|
'پاکسازی و تلاش مجدد' +
|
||||||
@@ -680,8 +763,8 @@
|
|||||||
async function cleanFetchAndReload() {
|
async function cleanFetchAndReload() {
|
||||||
renderRecovering('در حال دریافت تنظیمات جدید برنامه...')
|
renderRecovering('در حال دریافت تنظیمات جدید برنامه...')
|
||||||
|
|
||||||
var version = await loadRemoteVersion(5)
|
var version = await loadRemoteVersion(3)
|
||||||
var freshConfigResult = await fetchFreshConfig(5)
|
var freshConfigResult = await fetchFreshConfig(3)
|
||||||
|
|
||||||
clearReadableCookies()
|
clearReadableCookies()
|
||||||
// clearLocalStorageExceptToken()
|
// clearLocalStorageExceptToken()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"nm":"Pre-comp 1","ddd":0,"h":1200,"w":1200,"meta":{"g":"LottieFiles AE 0.1.21"},"layers":[{"ty":4,"nm":"arrow down Outlines","sr":1,"st":0,"op":1020,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[46.875,46.875,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[600,528,0],"t":0,"ti":[0,0,0],"to":[0,-15.333,0]},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[600,436,0],"t":13,"ti":[0,-15.333,0],"to":[0,0,0]},{"s":[600,528,0],"t":27}],"ix":2,"x":"var $bm_rt;\n$bm_rt = loopOut('cycle');"},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":4,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[7.831,7.793],[7.792,-7.828],[0,0],[0,0],[11.045,0],[0,-11.046],[0,0],[0,0],[7.829,-7.792],[-7.791,-7.829],[0,0],[-0.005,-0.006],[-7.819,7.833],[-0.005,0.006],[0,0]],"o":[[-7.829,-7.792],[0,0],[0,0],[0,-11.046],[-11.046,0],[0,0],[0,0],[-7.792,-7.828],[-7.83,7.793],[0,0],[0.006,0.007],[7.792,7.809],[0.006,-0.007],[0,0],[7.79,-7.828]],"v":[[118.109,117.328],[89.825,117.394],[20.001,187.557],[20.001,-236],[0.001,-256],[-19.999,-236],[-19.999,187.557],[-89.824,117.393],[-118.107,117.327],[-118.175,145.612],[-14.175,250.116],[-14.157,250.134],[14.158,250.135],[14.176,250.117],[118.176,145.613]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.1608,0.1608,0.3843],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[255.999,256],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":1},{"ty":4,"nm":"Shape Layer 1","sr":1,"st":0,"op":1020,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[141,141,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[622.56,380.04,0],"t":0,"ti":[0,0,0],"to":[0,-15.333,0]},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[622.56,288.04,0],"t":13,"ti":[0,-15.333,0],"to":[0,0,0]},{"s":[622.56,380.04,0],"t":27}],"ix":2,"x":"var $bm_rt;\n$bm_rt = loopOut('cycle');"},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Ellipse 1","ix":1,"cix":2,"np":3,"it":[{"ty":"el","bm":0,"hd":false,"mn":"ADBE Vector Shape - Ellipse","nm":"Ellipse Path 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"s":{"a":0,"k":[144,144],"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[1,1,1],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[-16,156],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2}],"v":"5.5.7","fr":30,"op":1020,"ip":0,"assets":[]}
|
||||||
File diff suppressed because one or more lines are too long
+39
-6
@@ -32,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</AConfigProvider>
|
</AConfigProvider>
|
||||||
<div
|
<div
|
||||||
v-if="showSupportButton && !showSplash && route.name !== 'support'"
|
v-if="showSupportButton && !showSplash && route.name !== 'support' && !isTvBoardRoute"
|
||||||
id="support-button"
|
id="support-button"
|
||||||
ref="supportButtonEl"
|
ref="supportButtonEl"
|
||||||
class="fixed z-[5100] cursor-grab select-none rounded-full"
|
class="fixed z-[5100] cursor-grab select-none rounded-full"
|
||||||
@@ -71,15 +71,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!showSplash && !isDesktop"
|
v-if="!showSplash && !isDesktop && !isTvBoardRoute"
|
||||||
class="app-credit-footer app-credit-footer--mobile"
|
class="app-credit-footer app-credit-footer--mobile"
|
||||||
|
:class="{ 'app-credit-footer--login': isLoginRoute }"
|
||||||
>
|
>
|
||||||
|
<EnamadBadge v-if="isLoginRoute" size="sm" />
|
||||||
<AppCreditLink variant="mobile" />
|
<AppCreditLink variant="mobile" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!showSplash && showDesktopFloatingCredit"
|
v-if="!showSplash && showDesktopFloatingCredit && !isTvBoardRoute"
|
||||||
class="app-credit-footer app-credit-footer--desktop-float"
|
class="app-credit-footer app-credit-footer--desktop-float"
|
||||||
|
:class="{ 'app-credit-footer--login': isLoginRoute }"
|
||||||
>
|
>
|
||||||
|
<EnamadBadge v-if="isLoginRoute" size="sm" />
|
||||||
<AppCreditLink variant="float" />
|
<AppCreditLink variant="float" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +97,7 @@
|
|||||||
<VersionUpdateModal />
|
<VersionUpdateModal />
|
||||||
|
|
||||||
<PwaInstallBanner
|
<PwaInstallBanner
|
||||||
v-if="!showSplash"
|
v-if="!showSplash && !isTvBoardRoute"
|
||||||
:visible="bannerVisible && !guideOpen"
|
:visible="bannerVisible && !guideOpen"
|
||||||
@install="openGuide()"
|
@install="openGuide()"
|
||||||
@dismiss="dismissBanner()"
|
@dismiss="dismissBanner()"
|
||||||
@@ -104,6 +108,8 @@
|
|||||||
:platform="platform"
|
:platform="platform"
|
||||||
@complete="completeGuide()"
|
@complete="completeGuide()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GoldDayCalculatorModal />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -118,12 +124,14 @@ import { useSupportMessenger } from '@/composables/useSupportMessenger'
|
|||||||
import { loadSupportMessengerFromSettings } from '@/utils/supportMessenger'
|
import { loadSupportMessengerFromSettings } from '@/utils/supportMessenger'
|
||||||
import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton'
|
import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton'
|
||||||
import AppCreditLink from '@/components/AppCreditLink.vue'
|
import AppCreditLink from '@/components/AppCreditLink.vue'
|
||||||
|
import EnamadBadge from '@/components/EnamadBadge.vue'
|
||||||
import SupportMessengerPicker from '@/components/SupportMessengerPicker.vue'
|
import SupportMessengerPicker from '@/components/SupportMessengerPicker.vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
|
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
|
||||||
import { usePwaInstall } from '@/composables/usePwaInstall'
|
import { usePwaInstall } from '@/composables/usePwaInstall'
|
||||||
import PwaInstallBanner from '@/components/pwa/PwaInstallBanner.vue'
|
import PwaInstallBanner from '@/components/pwa/PwaInstallBanner.vue'
|
||||||
import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue'
|
import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue'
|
||||||
|
import GoldDayCalculatorModal from '@/components/GoldDayCalculatorModal.vue'
|
||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
import { useSettingsStore } from '@/store/settingsStore'
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
import {
|
import {
|
||||||
@@ -149,9 +157,19 @@ const showDesktopFloatingCredit = computed(() => {
|
|||||||
if (!isDesktop.value) return false
|
if (!isDesktop.value) return false
|
||||||
return route.name === 'login' || !!route.meta.hideMenu
|
return route.name === 'login' || !!route.meta.hideMenu
|
||||||
})
|
})
|
||||||
|
const isLoginRoute = computed(() => route.name === 'login')
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const showSplash = ref(true)
|
|
||||||
|
function isTvBoardPath() {
|
||||||
|
const path = window.location.pathname.replace(/\/+$/, '')
|
||||||
|
return path.endsWith('/tv') || path.endsWith('/monitor')
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSplash = ref(!isTvBoardPath())
|
||||||
|
const isTvBoardRoute = computed(
|
||||||
|
() => route.name === 'tvMonitor' || route.meta.hideChrome === true || isTvBoardPath(),
|
||||||
|
)
|
||||||
const popVisible = ref(false)
|
const popVisible = ref(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -404,19 +422,34 @@ watch(latestIncomingNotification, (payload) => {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
min-height: var(--app-credit-bar-inner);
|
min-height: var(--app-credit-bar-inner);
|
||||||
padding-bottom: var(--app-safe-bottom);
|
padding: 4px 0 var(--app-safe-bottom);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 -2px 4px -2px #0000001a;
|
box-shadow: 0 -2px 4px -2px #0000001a;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-credit-footer--login.app-credit-footer--mobile {
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:has(.app-credit-footer--login) {
|
||||||
|
--app-credit-bar-inner: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-credit-footer--desktop-float {
|
.app-credit-footer--desktop-float {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-credit-link {
|
.app-credit-link {
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,8 @@
|
|||||||
import useAxios from "@/utils/useAxios";
|
import useAxios from '@/utils/useAxios'
|
||||||
|
import { getSuperAdminBase, resolveTenantDomain } from '@/config/superadmin'
|
||||||
|
|
||||||
export async function getConfig() {
|
export async function getConfig() {
|
||||||
return await useAxios.get(`https://nerkh.gold/get_user`)
|
const base = getSuperAdminBase() || 'https://adm.montzar.ir'
|
||||||
|
const domain = encodeURIComponent(resolveTenantDomain())
|
||||||
|
return await useAxios.get(`${base}/get_user?domain=${domain}`)
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,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`)
|
||||||
}
|
}
|
||||||
|
export const getTvProducts = async (): Promise<Product[]> => {
|
||||||
|
const response = await useAxios.get<unknown>(`/customer/tv/products`)
|
||||||
|
return Array.isArray(response) ? (response as Product[]) : []
|
||||||
|
}
|
||||||
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`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
fetchSuperAdminJson,
|
||||||
|
resolveTenantDomain,
|
||||||
|
SA_PROGRAM_SETTINGS_PATH,
|
||||||
|
} from '@/config/superadmin'
|
||||||
|
|
||||||
|
export type SuperAdminSettingRow = {
|
||||||
|
id?: number
|
||||||
|
key?: string
|
||||||
|
value?: string | number
|
||||||
|
title_fa?: string
|
||||||
|
description?: string
|
||||||
|
is_active?: boolean | number | string
|
||||||
|
type?: string
|
||||||
|
can_edit?: number | boolean | string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSettingsPayload(data: unknown): SuperAdminSettingRow[] {
|
||||||
|
if (Array.isArray(data)) return data as SuperAdminSettingRow[]
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
const obj = data as Record<string, unknown>
|
||||||
|
if (Array.isArray(obj.settings)) return obj.settings as SuperAdminSettingRow[]
|
||||||
|
if (Array.isArray(obj.items)) return obj.items as SuperAdminSettingRow[]
|
||||||
|
if (Array.isArray(obj.data)) return obj.data as SuperAdminSettingRow[]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** کلید قفلشده در get_settings محلی — باید از سوپرادمین بیاید */
|
||||||
|
export function isLockedCanEdit(canEdit: unknown): boolean {
|
||||||
|
return canEdit === 0 || canEdit === false || canEdit === '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تنظیمات پیشرفته نسخه از سوپرادمین — فقط GET /api/public/program-settings?domain= */
|
||||||
|
export async function fetchSuperAdminProgramSettings(): Promise<SuperAdminSettingRow[]> {
|
||||||
|
const domain = encodeURIComponent(resolveTenantDomain())
|
||||||
|
const data = await fetchSuperAdminJson(
|
||||||
|
(base) => `${base}${SA_PROGRAM_SETTINGS_PATH}?domain=${domain}`,
|
||||||
|
)
|
||||||
|
return parseSettingsPayload(data)
|
||||||
|
}
|
||||||
+10
-3
@@ -12,6 +12,8 @@ import { getToken } from '@/utils'
|
|||||||
import '@/utils/useDateTime'
|
import '@/utils/useDateTime'
|
||||||
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
|
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
|
||||||
import { initMobileScrollRestoration } from '@/layouts/composables/useMobileScrollReset'
|
import { initMobileScrollRestoration } from '@/layouts/composables/useMobileScrollReset'
|
||||||
|
import { initMobileInputKeyboardScroll } from '@/utils/mobileInputKeyboardScroll'
|
||||||
|
import { isServerUnavailableError } from '@/utils/serverUnavailable'
|
||||||
|
|
||||||
type BootWindow = Window & {
|
type BootWindow = Window & {
|
||||||
__BOOT_TIMEOUT__?: number
|
__BOOT_TIMEOUT__?: number
|
||||||
@@ -53,13 +55,13 @@ function showBootstrapError(error: unknown) {
|
|||||||
<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>
|
<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>
|
||||||
<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در راهاندازی برنامه</h2>
|
<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 style="margin: 0 0 20px; color: var(--boot-muted, rgba(15,23,42,0.62)); line-height: 2; font-size: 14px;">
|
||||||
لطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید.
|
برقراری ارتباط با سرور امکانپذیر نیست. لطفاً اتصال اینترنت خود را بررسی کرده و صفحه را تازهسازی نمایید.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick="location.reload()"
|
onclick="location.reload()"
|
||||||
style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent, #2563eb); color: white; cursor: pointer; font-weight: 700;"
|
style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent, #2563eb); color: white; cursor: pointer; font-weight: 700;"
|
||||||
>
|
>
|
||||||
تلاش مجدد
|
تازهسازی صفحه
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,12 +126,13 @@ export async function bootstrapApp() {
|
|||||||
|
|
||||||
setupRouterGuards()
|
setupRouterGuards()
|
||||||
initMobileScrollRestoration()
|
initMobileScrollRestoration()
|
||||||
|
initMobileInputKeyboardScroll()
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|
||||||
if (!getToken() && router.currentRoute.value.name !== 'login') {
|
if (!getToken() && router.currentRoute.value.name !== 'login' && router.currentRoute.value.name !== 'tvMonitor' && router.currentRoute.value.name !== 'aboutSoftware') {
|
||||||
await router.replace({ name: 'login' })
|
await router.replace({ name: 'login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +141,10 @@ export async function bootstrapApp() {
|
|||||||
sessionStorage.removeItem('__APP_BOOT_RECOVERY_DONE__')
|
sessionStorage.removeItem('__APP_BOOT_RECOVERY_DONE__')
|
||||||
sessionStorage.removeItem('__APP_BOOT_RETRY_CLICKED__')
|
sessionStorage.removeItem('__APP_BOOT_RETRY_CLICKED__')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isServerUnavailableError(error)) {
|
||||||
|
clearBootTimeout()
|
||||||
|
return
|
||||||
|
}
|
||||||
showBootstrapError(error)
|
showBootstrapError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-1
@@ -8,7 +8,7 @@ import { useAuthStore } from '@/store/useAuthStore'
|
|||||||
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
|
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
|
||||||
import { getFingerPosition, setFingerPosition } from '@/app/authBoot'
|
import { getFingerPosition, setFingerPosition } from '@/app/authBoot'
|
||||||
|
|
||||||
const PUBLIC_ROUTE_NAMES = ['login', '404', 'educate', 'aboutUs'] as const
|
const PUBLIC_ROUTE_NAMES = ['login', '404', 'educate', 'aboutUs', 'aboutSoftware', 'tvMonitor'] as const
|
||||||
|
|
||||||
function isPublicRoute(name: string | symbol | null | undefined) {
|
function isPublicRoute(name: string | symbol | null | undefined) {
|
||||||
return PUBLIC_ROUTE_NAMES.includes(name as (typeof PUBLIC_ROUTE_NAMES)[number])
|
return PUBLIC_ROUTE_NAMES.includes(name as (typeof PUBLIC_ROUTE_NAMES)[number])
|
||||||
@@ -88,6 +88,17 @@ export function setupRouterGuards() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.name === 'giftCard') {
|
||||||
|
const { useSettingsStore } = await import('@/store/settingsStore')
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
if (!settingsStore.loaded) {
|
||||||
|
await settingsStore.fetchSettings()
|
||||||
|
}
|
||||||
|
if (!settingsStore.isSettingTruthy('has_page_gift_card')) {
|
||||||
|
return next({ name: (from.name as string) || 'user' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (to.name === 'appNotifications') {
|
if (to.name === 'appNotifications') {
|
||||||
const { useSettingsStore } = await import('@/store/settingsStore')
|
const { useSettingsStore } = await import('@/store/settingsStore')
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
@@ -99,6 +110,33 @@ export function setupRouterGuards() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
to.name === 'piggy' ||
|
||||||
|
to.name === 'fund' ||
|
||||||
|
to.name === 'fund_info' ||
|
||||||
|
to.name === 'fund_add'
|
||||||
|
) {
|
||||||
|
const { useSettingsStore } = await import('@/store/settingsStore')
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
if (!settingsStore.loaded) {
|
||||||
|
await settingsStore.fetchSettings()
|
||||||
|
}
|
||||||
|
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
|
||||||
|
return next({ name: (from.name as string) || 'home' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.name === 'earnMoney') {
|
||||||
|
const { useSettingsStore } = await import('@/store/settingsStore')
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
if (!settingsStore.loaded) {
|
||||||
|
await settingsStore.fetchSettings()
|
||||||
|
}
|
||||||
|
if (!settingsStore.isSettingTruthy('has_page_earn_money')) {
|
||||||
|
return next({ name: (from.name as string) || 'user' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hasFingerPrint = !!userStore.userInfo?.finger_print
|
hasFingerPrint = !!userStore.userInfo?.finger_print
|
||||||
|
|
||||||
if (isApple && userStore.userInfo?.finger_print) {
|
if (isApple && userStore.userInfo?.finger_print) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
@import (less) '../../views/base/physicalReceive/desktop/styles/desktop-physical-receive.less';
|
@import (less) '../../views/base/physicalReceive/desktop/styles/desktop-physical-receive.less';
|
||||||
@import (less) '../../views/base/fund/desktop/styles/desktop-fund.less';
|
@import (less) '../../views/base/fund/desktop/styles/desktop-fund.less';
|
||||||
@import (less) '../../views/base/gold/desktop/styles/desktop-gold.less';
|
@import (less) '../../views/base/gold/desktop/styles/desktop-gold.less';
|
||||||
|
@import (less) './mobile-keyboard-scroll.less';
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
:root {
|
||||||
|
--app-keyboard-inset: 0px;
|
||||||
|
--app-visual-viewport-height: 100dvh;
|
||||||
|
--app-visual-viewport-offset-top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 767.98px) {
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
.ant-input,
|
||||||
|
.ant-input-affix-wrapper,
|
||||||
|
.ant-select-selection-search-input {
|
||||||
|
scroll-margin-bottom: calc(var(--app-keyboard-inset) + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-keyboard-open='true'] {
|
||||||
|
.main-with-bottom-chrome,
|
||||||
|
.login-vip-page__body,
|
||||||
|
.login-page,
|
||||||
|
.login-page-root,
|
||||||
|
.ant-modal-body,
|
||||||
|
.ant-drawer-body,
|
||||||
|
.van-action-sheet__content,
|
||||||
|
.basic-action-sheet__body {
|
||||||
|
scroll-padding-bottom: calc(var(--app-keyboard-inset) + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-with-bottom-chrome,
|
||||||
|
.login-vip-page__body,
|
||||||
|
.login-page {
|
||||||
|
padding-bottom: var(--app-keyboard-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-mobile {
|
||||||
|
height: var(--app-visual-viewport-height, 100dvh);
|
||||||
|
max-height: var(--app-visual-viewport-height, 100dvh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page.flex {
|
||||||
|
height: var(--app-visual-viewport-height, 92vh) !important;
|
||||||
|
max-height: var(--app-visual-viewport-height, 92vh) !important;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
variant?: 'mobile' | 'sidebar' | 'float'
|
variant?: 'mobile' | 'sidebar' | 'float' | 'tv'
|
||||||
}>(),
|
}>(),
|
||||||
{ variant: 'mobile' },
|
{ variant: 'mobile' },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,14 @@
|
|||||||
|
|
||||||
</style>-->
|
</style>-->
|
||||||
<template>
|
<template>
|
||||||
<VanActionSheet v-model:show="show" teleport="body" :z-index="zIndex">
|
<VanActionSheet
|
||||||
|
v-model:show="show"
|
||||||
|
teleport="body"
|
||||||
|
overlay
|
||||||
|
lock-scroll
|
||||||
|
close-on-click-overlay
|
||||||
|
:z-index="zIndex"
|
||||||
|
>
|
||||||
<div class="justify-center cursor-pointer flex mt-1" @click="show = false">
|
<div class="justify-center cursor-pointer flex mt-1" @click="show = false">
|
||||||
<span class="rounded-sm w-12 border-t-2 border-t-solid border-t-gray-500 mt-2"></span>
|
<span class="rounded-sm w-12 border-t-2 border-t-solid border-t-gray-500 mt-2"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,11 @@
|
|||||||
import {usePrefix} from "@/utils/usePrefix";
|
import {usePrefix} from "@/utils/usePrefix";
|
||||||
import {computed} from "vue";
|
import {computed} from "vue";
|
||||||
import {usePrice} from "@/utils/usePrice";
|
import {usePrice} from "@/utils/usePrice";
|
||||||
import {persianToEnglishDigits, shouldNormalizeInputDigits} from '@/utils/persianDigits';
|
import {
|
||||||
|
normalizeDecimalInput,
|
||||||
|
persianToEnglishDigits,
|
||||||
|
shouldNormalizeDecimalSeparators,
|
||||||
|
} from '@/utils/persianDigits';
|
||||||
|
|
||||||
//Props
|
//Props
|
||||||
interface Prop {
|
interface Prop {
|
||||||
@@ -81,19 +85,23 @@ function normalizeValue(value: unknown) {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalized = String(value)
|
if (shouldNormalizeDecimalSeparators(props)) {
|
||||||
if (shouldNormalizeInputDigits(props)) {
|
const isPrice = String(props.type ?? '').trim().toLowerCase() === 'price'
|
||||||
normalized = persianToEnglishDigits(normalized)
|
return normalizeDecimalInput(value as string | number, {
|
||||||
|
commaAsThousand: isPrice,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (props.type === 'price') {
|
|
||||||
normalized = normalized.replaceAll(',', '')
|
return persianToEnglishDigits(String(value))
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlerChange = (e: Event | string) => {
|
const handlerChange = (e: Event | string) => {
|
||||||
const raw = typeof e === 'string' ? e : (e.target as HTMLInputElement | null)?.value
|
const target = typeof e === 'string' ? null : (e.target as HTMLInputElement | null)
|
||||||
|
const raw = typeof e === 'string' ? e : target?.value
|
||||||
const normalized = normalizeValue(raw)
|
const normalized = normalizeValue(raw)
|
||||||
|
if (target && typeof normalized === 'string' && target.value !== normalized) {
|
||||||
|
target.value = normalized
|
||||||
|
}
|
||||||
emits('update:value', normalized)
|
emits('update:value', normalized)
|
||||||
emits('onchange', normalized)
|
emits('onchange', normalized)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +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'
|
import {normalizeDecimalInput} from '@/utils/persianDigits'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string | number
|
value?: string | number
|
||||||
@@ -24,7 +24,7 @@ const props = withDefaults(defineProps<Props>(), {})
|
|||||||
|
|
||||||
const _val = computed({
|
const _val = computed({
|
||||||
set(val: string) {
|
set(val: string) {
|
||||||
emit('update:value', persianToEnglishDigits(val).replaceAll(',', ''))
|
emit('update:value', normalizeDecimalInput(val))
|
||||||
},
|
},
|
||||||
get() {
|
get() {
|
||||||
return props.value ? usePrice(props.value as number, true, '').trim() : ''
|
return props.value ? usePrice(props.value as number, true, '').trim() : ''
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<a
|
||||||
|
v-if="href && src"
|
||||||
|
referrerpolicy="origin"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:href="href"
|
||||||
|
class="enamad-badge"
|
||||||
|
:class="`enamad-badge--${size}`"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
referrerpolicy="origin"
|
||||||
|
:src="src"
|
||||||
|
alt="enamad"
|
||||||
|
class="enamad-badge__img"
|
||||||
|
:code="code || undefined"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
size?: 'sm' | 'lg'
|
||||||
|
}>(),
|
||||||
|
{ size: 'sm' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
const href = computed(() =>
|
||||||
|
String(settingsStore.getSettingByKey('enamad_url') ?? '').trim(),
|
||||||
|
)
|
||||||
|
const src = computed(() =>
|
||||||
|
String(settingsStore.getSettingByKey('enamad_logo') ?? '').trim(),
|
||||||
|
)
|
||||||
|
const code = computed(() => {
|
||||||
|
try {
|
||||||
|
const u = new URL(href.value || src.value)
|
||||||
|
return u.searchParams.get('Code') || u.searchParams.get('code') || ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.enamad-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enamad-badge__img {
|
||||||
|
width: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enamad-badge--sm .enamad-badge__img {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enamad-badge--lg .enamad-badge__img {
|
||||||
|
height: 120px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<template>
|
||||||
|
<BasicModal
|
||||||
|
v-model:visible="visible"
|
||||||
|
width="400px"
|
||||||
|
destroy-on-close
|
||||||
|
custom-footer
|
||||||
|
wrap-class-name="gold-day-calc-modal"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="gold-day-calc-modal__title">
|
||||||
|
<BasicIcon icon="vuesax-linear:chart-2" size="20" class="gold-day-calc-modal__title-icon" />
|
||||||
|
<span>محاسبهگر طلای شما</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="gold-day-calc" dir="rtl">
|
||||||
|
<BasicInput
|
||||||
|
v-model:value="weight"
|
||||||
|
title="وزن طلا (گرم)"
|
||||||
|
type="tel"
|
||||||
|
place-holder="مثلاً ۱۰.۵"
|
||||||
|
inputmode="decimal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BasicInput
|
||||||
|
:value="unitPriceInput"
|
||||||
|
title="قیمت روز طلا"
|
||||||
|
type="tel"
|
||||||
|
disabled
|
||||||
|
addon-before="ریال"
|
||||||
|
place-holder="قیمت صفحه اصلی"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BasicInput
|
||||||
|
v-model:value="feePercent"
|
||||||
|
title="اجرت ساخت (%)"
|
||||||
|
type="tel"
|
||||||
|
place-holder="مثلاً ۷"
|
||||||
|
inputmode="decimal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BasicInput
|
||||||
|
v-model:value="taxPercent"
|
||||||
|
title="سود و مالیات (%)"
|
||||||
|
type="tel"
|
||||||
|
place-holder="مثلاً ۹"
|
||||||
|
inputmode="decimal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="gold-day-calc__result rounded-xl border border-solid border-gray-200 bg-gray-50 p-3">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">کل نهایی</div>
|
||||||
|
<div class="gold-day-calc__total text-lg font-bold text-primary">
|
||||||
|
<span class="gold-day-calc__num">{{ formattedTotal }}</span>
|
||||||
|
<span class="text-sm font-medium text-gray-600">ریال</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-col gap-1 text-xs text-gray-600">
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<span>هزینه طلا</span>
|
||||||
|
<span class="gold-day-calc__num">{{ formatRial(breakdown.goldCost) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<span>اجرت</span>
|
||||||
|
<span class="gold-day-calc__num">{{ formatRial(breakdown.feeCost) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<span>سود و مالیات</span>
|
||||||
|
<span class="gold-day-calc__num">{{ formatRial(breakdown.taxCost) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<BasicButton type="primary" class="!w-full" size="middle" @click="recalculate">
|
||||||
|
محاسبه مجدد
|
||||||
|
</BasicButton>
|
||||||
|
</template>
|
||||||
|
</BasicModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
|
||||||
|
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
|
||||||
|
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import { goldDayCalculatorVisible } from '@/composables/useGoldDayCalculator'
|
||||||
|
import {
|
||||||
|
getLastPrices,
|
||||||
|
getTradeBuyPrice,
|
||||||
|
lastPrice,
|
||||||
|
startLastPricePolling,
|
||||||
|
stopLastPricePolling,
|
||||||
|
} from '@/utils/price'
|
||||||
|
import { getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
|
||||||
|
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||||
|
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
||||||
|
|
||||||
|
const visible = goldDayCalculatorVisible
|
||||||
|
|
||||||
|
const weight = ref<string | number>('')
|
||||||
|
const feePercent = ref<string | number>('')
|
||||||
|
const taxPercent = ref<string | number>('')
|
||||||
|
const tick = ref(0)
|
||||||
|
|
||||||
|
function parseNum(raw: string | number | undefined): number {
|
||||||
|
const s = persianToEnglishDigits(String(raw ?? ''))
|
||||||
|
.replace(/,/g, '')
|
||||||
|
.replace(/[^\d.]/g, '')
|
||||||
|
const n = Number.parseFloat(s)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveUnitPrice = computed(() => {
|
||||||
|
void tick.value
|
||||||
|
if (getPriceDisplayState() !== 'open') return 0
|
||||||
|
return getTradeBuyPrice(lastPrice.value) || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const unitPriceInput = computed(() => {
|
||||||
|
void tick.value
|
||||||
|
const state = getPriceDisplayState()
|
||||||
|
if (state === 'closed') return 'فروشگاه بسته است'
|
||||||
|
if (state === 'no_quote') return NO_QUOTE_MESSAGE
|
||||||
|
const p = liveUnitPrice.value
|
||||||
|
return p > 0 ? usePriceWithoutType(p) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const breakdown = computed(() => {
|
||||||
|
void tick.value
|
||||||
|
const w = parseNum(weight.value)
|
||||||
|
const fee = parseNum(feePercent.value)
|
||||||
|
const tax = parseNum(taxPercent.value)
|
||||||
|
const unit = liveUnitPrice.value
|
||||||
|
const goldCost = w * unit
|
||||||
|
const feeCost = goldCost * (fee / 100)
|
||||||
|
const taxCost = (goldCost + feeCost) * (tax / 100)
|
||||||
|
const total = goldCost + feeCost + taxCost
|
||||||
|
return { goldCost, feeCost, taxCost, total }
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedTotal = computed(() => formatRial(breakdown.value.total))
|
||||||
|
|
||||||
|
function formatRial(n: number) {
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return '۰'
|
||||||
|
return usePriceWithoutType(Math.round(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalculate() {
|
||||||
|
tick.value += 1
|
||||||
|
void getLastPrices()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, async (open) => {
|
||||||
|
if (open) {
|
||||||
|
startLastPricePolling()
|
||||||
|
await getLastPrices()
|
||||||
|
tick.value += 1
|
||||||
|
} else {
|
||||||
|
stopLastPricePolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gold-day-calc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-day-calc :deep(.ant-input-group) {
|
||||||
|
direction: ltr;
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-day-calc :deep(.ant-input-group > .ant-input) {
|
||||||
|
direction: ltr;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-day-calc :deep(.ant-input:not(.ant-input-group *)) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-day-calc__result {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-day-calc__total {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-day-calc__num {
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
.gold-day-calc-modal {
|
||||||
|
.ant-modal-content {
|
||||||
|
direction: rtl;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: min(88dvh, 640px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
padding-left: 2.75rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-title {
|
||||||
|
text-align: right;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-day-calc-modal__title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
direction: rtl;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-day-calc-modal__title-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--App-secondary-color, #c9a062);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-close {
|
||||||
|
inset-inline-start: 0 !important;
|
||||||
|
inset-inline-end: auto !important;
|
||||||
|
right: auto !important;
|
||||||
|
left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#body {
|
||||||
|
max-height: min(48dvh, 360px) !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 767.98px) {
|
||||||
|
.gold-day-calc-modal {
|
||||||
|
.ant-modal {
|
||||||
|
max-width: calc(100vw - 1.5rem);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-content {
|
||||||
|
max-height: min(86dvh, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#body {
|
||||||
|
max-height: min(42dvh, 280px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-body {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 400px) {
|
||||||
|
.gold-day-calc-modal #body {
|
||||||
|
max-height: min(38dvh, 240px) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<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 { getProductDisplayImage } from '@/utils/productAssets'
|
||||||
|
import type { Product } from '@/models/products'
|
||||||
|
import defaultImg from '@/assets/img/coin.png'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
product?: Product | null
|
||||||
|
imgClass?: string
|
||||||
|
alt?: string
|
||||||
|
dimmed?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
imgClass: 'max-h-full max-w-full object-contain',
|
||||||
|
alt: '',
|
||||||
|
dimmed: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function productImageKey(product?: Product | null): string {
|
||||||
|
return [
|
||||||
|
product?.file_id ?? '',
|
||||||
|
product?.file?.id ?? '',
|
||||||
|
product?.file?.full_src ?? '',
|
||||||
|
product?.file?.src ?? '',
|
||||||
|
product?.file?.url ?? '',
|
||||||
|
product?.img ?? '',
|
||||||
|
].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSrc = ref(getProductDisplayImage(props.product) || defaultImg)
|
||||||
|
const usingFallback = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => productImageKey(props.product),
|
||||||
|
() => {
|
||||||
|
usingFallback.value = false
|
||||||
|
currentSrc.value = getProductDisplayImage(props.product) || defaultImg
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleError() {
|
||||||
|
if (usingFallback.value) return
|
||||||
|
usingFallback.value = true
|
||||||
|
currentSrc.value = defaultImg
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-image--dimmed {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<template>
|
||||||
|
<div class="trade-fee-summary">
|
||||||
|
<div class="trade-fee-summary__quote">
|
||||||
|
<div class="trade-fee-summary__label">مظنه هر {{ weightUnit }}</div>
|
||||||
|
<div class="trade-fee-summary__value trade-fee-summary__value--strong">
|
||||||
|
<span>{{ formattedUnitPrice }}</span>
|
||||||
|
<span class="trade-fee-summary__unit">ریال</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(section, index) in breakdown.sections"
|
||||||
|
:key="section.heading"
|
||||||
|
class="trade-fee-summary__section"
|
||||||
|
:class="{ 'trade-fee-summary__section--divided': index > 0 }"
|
||||||
|
>
|
||||||
|
<div class="trade-fee-summary__heading">{{ section.heading }}</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(row, rowIndex) in section.rows"
|
||||||
|
:key="`${section.heading}-${rowIndex}`"
|
||||||
|
class="trade-fee-summary__row"
|
||||||
|
:class="{
|
||||||
|
'trade-fee-summary__row--divider': row.dividerBefore,
|
||||||
|
'trade-fee-summary__row--bold': row.bold,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="trade-fee-summary__label"
|
||||||
|
:class="{ 'trade-fee-summary__label--strong': row.bold }"
|
||||||
|
>
|
||||||
|
<span v-if="row.plus" class="trade-fee-summary__sign trade-fee-summary__sign--plus">+</span>
|
||||||
|
<span v-else-if="row.minus && !row.bold" class="trade-fee-summary__sign trade-fee-summary__sign--minus">−</span>
|
||||||
|
{{ row.label }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="trade-fee-summary__value"
|
||||||
|
:class="{
|
||||||
|
'trade-fee-summary__value--minus': row.minus && !row.bold,
|
||||||
|
'trade-fee-summary__value--plus': row.plus,
|
||||||
|
'trade-fee-summary__value--strong': row.bold,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template v-if="row.gram != null">
|
||||||
|
<span>{{ formatTradeGramDisplay(row.gram) }}</span>
|
||||||
|
<span class="trade-fee-summary__unit">{{ weightUnit }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="row.rial != null">
|
||||||
|
<span v-if="row.minus && row.bold">−</span>
|
||||||
|
<span>{{ formatRial(row.rial) }}</span>
|
||||||
|
<span class="trade-fee-summary__unit">ریال</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="section.footnote" class="trade-fee-summary__footnote">
|
||||||
|
{{ section.footnote }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||||
|
import {
|
||||||
|
buildTradeConfirmBreakdown,
|
||||||
|
formatTradeGramDisplay,
|
||||||
|
type TradeInputLock,
|
||||||
|
} from '@/utils/serviceFee'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isBuy: boolean
|
||||||
|
inputLock: TradeInputLock
|
||||||
|
displayTradeGoldAmount: number | string
|
||||||
|
weightUnit: string
|
||||||
|
unitPriceRial: number
|
||||||
|
feeInGrams: number
|
||||||
|
equivalentPrice: string
|
||||||
|
payablePrice: number
|
||||||
|
orderGoldAmount: number
|
||||||
|
feeInRial: number
|
||||||
|
sellGoldDebit?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formattedUnitPrice = computed(() =>
|
||||||
|
usePriceWithoutType(Math.round(props.unitPriceRial)),
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatRial(value: number) {
|
||||||
|
return usePriceWithoutType(Math.round(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakdown = computed(() =>
|
||||||
|
buildTradeConfirmBreakdown({
|
||||||
|
isBuy: props.isBuy,
|
||||||
|
inputLock: props.inputLock,
|
||||||
|
orderGoldAmount: props.orderGoldAmount,
|
||||||
|
feeInGrams: props.feeInGrams,
|
||||||
|
feeInRial: props.feeInRial,
|
||||||
|
payablePrice: props.payablePrice,
|
||||||
|
unitPriceRial: props.unitPriceRial,
|
||||||
|
sellGoldDebit: props.sellGoldDebit,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.trade-fee-summary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--app-surface-inset, var(--app-surface));
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-fee-summary__quote {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-fee-summary__section {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
|
||||||
|
&--divided {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-fee-summary__heading {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-text-primary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-fee-summary__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
|
||||||
|
&--divider {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px dashed var(--app-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bold {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-fee-summary__label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-text-secondary);
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
&--strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-fee-summary__value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-primary);
|
||||||
|
text-align: left;
|
||||||
|
direction: ltr;
|
||||||
|
|
||||||
|
&--strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--minus {
|
||||||
|
color: var(--app-down, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--plus {
|
||||||
|
color: var(--app-up, #22c55e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-fee-summary__unit {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-fee-summary__sign {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
|
||||||
|
&--plus {
|
||||||
|
color: var(--app-up, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--minus {
|
||||||
|
color: var(--app-down, #ef4444);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-fee-summary__footnote {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--app-surface, rgba(255, 255, 255, 0.04));
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--app-text-secondary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/** باز/بسته بودن مدال محاسبهگر طلا — مشترک بین منوی کاربر و همبرگر */
|
||||||
|
export const goldDayCalculatorVisible = ref(false)
|
||||||
|
|
||||||
|
export function openGoldDayCalculator() {
|
||||||
|
goldDayCalculatorVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeGoldDayCalculator() {
|
||||||
|
goldDayCalculatorVisible.value = false
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useConfigStore } from '@/store/configStore'
|
import { useConfigStore } from '@/store/configStore'
|
||||||
import {
|
import {
|
||||||
getProjectBaleContactLink,
|
getProjectBaleContactLink,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '@/utils/supportMessenger'
|
} from '@/utils/supportMessenger'
|
||||||
import {
|
import {
|
||||||
copyDepositFieldValue,
|
copyDepositFieldValue,
|
||||||
|
displayDepositAccountNumber,
|
||||||
displayDepositCardNumber,
|
displayDepositCardNumber,
|
||||||
displayDepositSheba,
|
displayDepositSheba,
|
||||||
getDepositGuideVarsFromSettings,
|
getDepositGuideVarsFromSettings,
|
||||||
@@ -51,10 +52,15 @@ export function useSupportMessenger() {
|
|||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const supportPickerOpen = supportMessengerPickerOpen
|
const supportPickerOpen = supportMessengerPickerOpen
|
||||||
const supportPickerIntent = ref<SupportPickerIntent>('plain')
|
const supportPickerIntent = ref<SupportPickerIntent>('plain')
|
||||||
|
|
||||||
const isChatEnabled = computed(() => isUserSupportChatEnabled())
|
/** چت فقط بعد از لاگین — روی صفحه ورود در picker نیاید */
|
||||||
|
const isChatEnabled = computed(() => {
|
||||||
|
void route.name
|
||||||
|
return isUserSupportChatEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
const legacySupportLink = computed(() => {
|
const legacySupportLink = computed(() => {
|
||||||
if (config.config?.name === 'garoosi' || isBazretalaProject()) {
|
if (config.config?.name === 'garoosi' || isBazretalaProject()) {
|
||||||
@@ -223,6 +229,13 @@ export function useSupportMessenger() {
|
|||||||
return String(getDepositGuideVarsFromSettings().cardNumber ?? '').trim()
|
return String(getDepositGuideVarsFromSettings().cardNumber ?? '').trim()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const depositAccountNumberRaw = computed(() => {
|
||||||
|
void settingsStore.settings
|
||||||
|
const fromMessenger = supportMessengerSettings.value.depositAccountNumber?.trim()
|
||||||
|
if (fromMessenger) return fromMessenger
|
||||||
|
return String(getDepositGuideVarsFromSettings().accountNumber ?? '').trim()
|
||||||
|
})
|
||||||
|
|
||||||
const depositShebaRaw = computed(() => {
|
const depositShebaRaw = computed(() => {
|
||||||
void settingsStore.settings
|
void settingsStore.settings
|
||||||
const fromMessenger = supportMessengerSettings.value.depositSheba?.trim()
|
const fromMessenger = supportMessengerSettings.value.depositSheba?.trim()
|
||||||
@@ -231,6 +244,9 @@ export function useSupportMessenger() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const depositCardNumber = computed(() => displayDepositCardNumber(depositCardNumberRaw.value))
|
const depositCardNumber = computed(() => displayDepositCardNumber(depositCardNumberRaw.value))
|
||||||
|
const depositAccountNumber = computed(() =>
|
||||||
|
displayDepositAccountNumber(depositAccountNumberRaw.value),
|
||||||
|
)
|
||||||
const depositSheba = computed(() => displayDepositSheba(depositShebaRaw.value))
|
const depositSheba = computed(() => displayDepositSheba(depositShebaRaw.value))
|
||||||
|
|
||||||
const depositCardLabel = computed(() => {
|
const depositCardLabel = computed(() => {
|
||||||
@@ -238,12 +254,20 @@ export function useSupportMessenger() {
|
|||||||
return getDepositSettingLabel('deposit_card_number', 'شماره کارت واریز')
|
return getDepositSettingLabel('deposit_card_number', 'شماره کارت واریز')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const depositAccountNumberLabel = computed(() => {
|
||||||
|
void settingsStore.settings
|
||||||
|
return getDepositSettingLabel('deposit_account_number', 'شماره حساب واریز')
|
||||||
|
})
|
||||||
|
|
||||||
const depositShebaLabel = computed(() => {
|
const depositShebaLabel = computed(() => {
|
||||||
void settingsStore.settings
|
void settingsStore.settings
|
||||||
return getDepositSettingLabel('deposit_sheba', 'شبا واریز')
|
return getDepositSettingLabel('deposit_sheba', 'شبا واریز')
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasDepositCardValue = computed(() => hasDepositFieldValue(depositCardNumberRaw.value))
|
const hasDepositCardValue = computed(() => hasDepositFieldValue(depositCardNumberRaw.value))
|
||||||
|
const hasDepositAccountNumberValue = computed(() =>
|
||||||
|
hasDepositFieldValue(depositAccountNumberRaw.value),
|
||||||
|
)
|
||||||
const hasDepositShebaValue = computed(() => hasDepositFieldValue(depositShebaRaw.value))
|
const hasDepositShebaValue = computed(() => hasDepositFieldValue(depositShebaRaw.value))
|
||||||
|
|
||||||
async function copyDepositCardNumber() {
|
async function copyDepositCardNumber() {
|
||||||
@@ -254,6 +278,14 @@ export function useSupportMessenger() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyDepositAccountNumber() {
|
||||||
|
await copyDepositFieldValue(
|
||||||
|
depositAccountNumberRaw.value,
|
||||||
|
'account',
|
||||||
|
'شماره حساب ثبت نشده است',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function copyDepositShebaNumber() {
|
async function copyDepositShebaNumber() {
|
||||||
await copyDepositFieldValue(
|
await copyDepositFieldValue(
|
||||||
depositShebaRaw.value,
|
depositShebaRaw.value,
|
||||||
@@ -283,14 +315,19 @@ export function useSupportMessenger() {
|
|||||||
supportMessengerText,
|
supportMessengerText,
|
||||||
depositSupportMessage,
|
depositSupportMessage,
|
||||||
depositCardNumber,
|
depositCardNumber,
|
||||||
|
depositAccountNumber,
|
||||||
depositSheba,
|
depositSheba,
|
||||||
depositCardNumberRaw,
|
depositCardNumberRaw,
|
||||||
|
depositAccountNumberRaw,
|
||||||
depositShebaRaw,
|
depositShebaRaw,
|
||||||
depositCardLabel,
|
depositCardLabel,
|
||||||
|
depositAccountNumberLabel,
|
||||||
depositShebaLabel,
|
depositShebaLabel,
|
||||||
hasDepositCardValue,
|
hasDepositCardValue,
|
||||||
|
hasDepositAccountNumberValue,
|
||||||
hasDepositShebaValue,
|
hasDepositShebaValue,
|
||||||
copyDepositCardNumber,
|
copyDepositCardNumber,
|
||||||
|
copyDepositAccountNumber,
|
||||||
copyDepositShebaNumber,
|
copyDepositShebaNumber,
|
||||||
openSupportMessenger,
|
openSupportMessenger,
|
||||||
openDepositSupportMessenger,
|
openDepositSupportMessenger,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { fetchTrackingSettings, isTrackingSystemsEnabled } from '@/utils/trackingSystemsSettings'
|
||||||
|
|
||||||
|
export function useTrackingSystemsFeature() {
|
||||||
|
const trackingSystemsEnabled = ref(false)
|
||||||
|
const trackingSystemsReady = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const settings = await fetchTrackingSettings()
|
||||||
|
trackingSystemsEnabled.value = isTrackingSystemsEnabled(settings)
|
||||||
|
} finally {
|
||||||
|
trackingSystemsReady.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackingSystemsEnabled,
|
||||||
|
trackingSystemsReady,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,13 +71,26 @@ function setAppleTouchIconFallback() {
|
|||||||
appleTitle.content = getConfig('name_fa', 'اپ مشتری')
|
appleTitle.content = getConfig('name_fa', 'اپ مشتری')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveConfigBase(url: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
parsed.search = ''
|
||||||
|
parsed.hash = ''
|
||||||
|
parsed.pathname = parsed.pathname.replace(/\/get_user\/?$/i, '')
|
||||||
|
if (!parsed.pathname || parsed.pathname === '/') {
|
||||||
|
return parsed.origin
|
||||||
|
}
|
||||||
|
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
|
||||||
|
} catch {
|
||||||
|
return url.replace(/[?#].*$/, '').replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRemoteLogo(path: string) {
|
function resolveRemoteLogo(path: string) {
|
||||||
if (/^(https?:\/\/|data:|blob:)/i.test(path)) return path
|
if (/^(https?:\/\/|data:|blob:)/i.test(path)) return path
|
||||||
|
|
||||||
const lastSuccess = localStorage.getItem('CONFIG_Success_URL')
|
const lastSuccess = localStorage.getItem('CONFIG_Success_URL')
|
||||||
const base = lastSuccess
|
const base = lastSuccess ? resolveConfigBase(lastSuccess) : 'https://adm.montzar.ir'
|
||||||
? lastSuccess.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
|
|
||||||
: 'https://nerkh.gold'
|
|
||||||
|
|
||||||
return `${base}/${path.replace(/^\/+/, '')}`
|
return `${base}/${path.replace(/^\/+/, '')}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { AppConfig } from '@/config/types'
|
import type { AppConfig } from '@/config/types'
|
||||||
import { resetVersionCheckCache } from '@/config/load-version'
|
import { resetVersionCheckCache } from '@/config/load-version'
|
||||||
import { clearCachedPwaManifest } from '@/modules/pwa/manifest-cache'
|
import { clearCachedPwaManifest } from '@/modules/pwa/manifest-cache'
|
||||||
|
import { resolveTenantDomain } from '@/config/superadmin'
|
||||||
import { collectAuthTokensForPreserve } from '@/utils/authToken'
|
import { collectAuthTokensForPreserve } from '@/utils/authToken'
|
||||||
|
|
||||||
const CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
|
const CONFIG_COOKIE_NAME = 'APP_CONFIG_CACHE'
|
||||||
|
const APP_CONFIG_LS_KEY = 'APP_CONFIG_LS_CACHE'
|
||||||
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||||
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
||||||
const CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
|
const CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
|
||||||
@@ -18,6 +20,13 @@ type CachedConfigPayload = {
|
|||||||
config: AppConfig
|
config: AppConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VersionedAppConfigCache = {
|
||||||
|
version: string
|
||||||
|
domain: string
|
||||||
|
cached_at: number
|
||||||
|
config: AppConfig
|
||||||
|
}
|
||||||
|
|
||||||
type VersionUpdateLock = {
|
type VersionUpdateLock = {
|
||||||
version: string
|
version: string
|
||||||
started_at: number
|
started_at: number
|
||||||
@@ -62,6 +71,54 @@ export function readCachedAppConfig(): AppConfig | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** کش بزرگ get_user در localStorage — با version باطل میشود */
|
||||||
|
export function readVersionedAppConfigCache(domain?: string): VersionedAppConfigCache | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(APP_CONFIG_LS_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const payload = JSON.parse(raw) as VersionedAppConfigCache
|
||||||
|
if (!payload?.config || !payload.version) return null
|
||||||
|
|
||||||
|
if (domain && payload.domain && payload.domain !== domain) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read versioned app config cache:', error)
|
||||||
|
clearVersionedAppConfigCache()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeVersionedAppConfigCache(
|
||||||
|
config: AppConfig,
|
||||||
|
version: string,
|
||||||
|
domain: string,
|
||||||
|
) {
|
||||||
|
const normalizedVersion = String(version || '').trim()
|
||||||
|
if (!normalizedVersion) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: VersionedAppConfigCache = {
|
||||||
|
version: normalizedVersion,
|
||||||
|
domain: String(domain || '').trim(),
|
||||||
|
cached_at: Date.now(),
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
localStorage.setItem(APP_CONFIG_LS_KEY, JSON.stringify(payload))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to write versioned app config cache:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeCachedAppConfig(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearVersionedAppConfigCache() {
|
||||||
|
localStorage.removeItem(APP_CONFIG_LS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
export function writeCachedAppConfig(config: AppConfig) {
|
export function writeCachedAppConfig(config: AppConfig) {
|
||||||
try {
|
try {
|
||||||
const payload: CachedConfigPayload = {
|
const payload: CachedConfigPayload = {
|
||||||
@@ -84,6 +141,7 @@ export function writeCachedAppConfig(config: AppConfig) {
|
|||||||
|
|
||||||
export function clearCachedAppConfig() {
|
export function clearCachedAppConfig() {
|
||||||
document.cookie = `${CONFIG_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax`
|
document.cookie = `${CONFIG_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax`
|
||||||
|
clearVersionedAppConfigCache()
|
||||||
clearCachedPwaManifest()
|
clearCachedPwaManifest()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +281,7 @@ export function clearAppStorageExceptToken() {
|
|||||||
export function clearAllBootstrapFetchCaches() {
|
export function clearAllBootstrapFetchCaches() {
|
||||||
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
|
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
|
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
|
||||||
|
clearVersionedAppConfigCache()
|
||||||
clearCachedPwaManifest()
|
clearCachedPwaManifest()
|
||||||
resetVersionCheckCache()
|
resetVersionCheckCache()
|
||||||
window.__APP_CONFIG__ = undefined
|
window.__APP_CONFIG__ = undefined
|
||||||
@@ -253,6 +312,7 @@ export async function refreshConfigCacheForVersion(
|
|||||||
await clearBrowserRuntimeCaches()
|
await clearBrowserRuntimeCaches()
|
||||||
|
|
||||||
writeCachedAppConfig(freshConfig)
|
writeCachedAppConfig(freshConfig)
|
||||||
|
writeVersionedAppConfigCache(freshConfig, normalizedVersion, resolveTenantDomain())
|
||||||
finishVersionUpdateBeforeReload(normalizedVersion)
|
finishVersionUpdateBeforeReload(normalizedVersion)
|
||||||
|
|
||||||
return freshConfig
|
return freshConfig
|
||||||
@@ -275,6 +335,7 @@ export async function resetAppAndRefreshConfig(
|
|||||||
|
|
||||||
if (normalizedVersion) {
|
if (normalizedVersion) {
|
||||||
writeStoredAppVersion(normalizedVersion)
|
writeStoredAppVersion(normalizedVersion)
|
||||||
|
writeVersionedAppConfigCache(freshConfig, normalizedVersion, resolveTenantDomain())
|
||||||
}
|
}
|
||||||
|
|
||||||
return freshConfig
|
return freshConfig
|
||||||
|
|||||||
+220
-34
@@ -1,33 +1,54 @@
|
|||||||
import type { AppConfig } from '@/config/types'
|
import type { AppConfig } from '@/config/types'
|
||||||
import { readCachedAppConfig, writeCachedAppConfig } from '@/config/config-cache'
|
import {
|
||||||
|
clearCachedAppConfig,
|
||||||
|
getNormalizedVersion,
|
||||||
|
readCachedAppConfig,
|
||||||
|
readVersionedAppConfigCache,
|
||||||
|
writeCachedAppConfig,
|
||||||
|
writeStoredAppVersion,
|
||||||
|
writeVersionedAppConfigCache,
|
||||||
|
} from '@/config/config-cache'
|
||||||
|
import { loadRemoteAppVersion } from '@/config/load-version'
|
||||||
|
import {
|
||||||
|
assertServerReachableConfig,
|
||||||
|
hideServerUnavailableOverlay,
|
||||||
|
isServerUnreachableConfig,
|
||||||
|
} from '@/utils/serverUnavailable'
|
||||||
|
import {
|
||||||
|
allowSuperAdminFallback,
|
||||||
|
getSuperAdminBase,
|
||||||
|
getSuperAdminBases,
|
||||||
|
isSuperAdminUrl,
|
||||||
|
resolveTenantDomain,
|
||||||
|
SA_RETRY_ROUNDS,
|
||||||
|
} from '@/config/superadmin'
|
||||||
|
|
||||||
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
const CONFIG_SUCCESS_URL_KEY = 'CONFIG_Success_URL'
|
||||||
export const CONFIG_RETRY_ROUNDS = 5
|
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
||||||
|
export const CONFIG_RETRY_ROUNDS = SA_RETRY_ROUNDS
|
||||||
|
|
||||||
function getProtocol() {
|
export { getSuperAdminBase, resolveTenantDomain } from '@/config/superadmin'
|
||||||
return window.location.protocol === 'https:' ? 'https:' : 'http:'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** فقط SuperAdmin — بدون رجیستری قدیمی */
|
||||||
export function getConfigUrls() {
|
export function getConfigUrls() {
|
||||||
const protocol = getProtocol()
|
const domain = encodeURIComponent(resolveTenantDomain())
|
||||||
|
return getSuperAdminBases().map((base) => `${base}/get_user?domain=${domain}`)
|
||||||
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`
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** اولویت با last successful SuperAdmin؛ رجیستری قدیمی sticky نشود */
|
||||||
export function sortConfigUrls(urls: string[]) {
|
export function sortConfigUrls(urls: string[]) {
|
||||||
const lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
const lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
if (
|
||||||
return [...urls].sort((a, b) => {
|
!lastSuccessUrl ||
|
||||||
if (a === lastSuccessUrl) return -1
|
!urls.includes(lastSuccessUrl) ||
|
||||||
if (b === lastSuccessUrl) return 1
|
!isSuperAdminUrl(lastSuccessUrl)
|
||||||
return 0
|
) {
|
||||||
})
|
if (lastSuccessUrl && !isSuperAdminUrl(lastSuccessUrl)) {
|
||||||
|
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
}
|
||||||
|
return [...urls]
|
||||||
|
}
|
||||||
|
return [lastSuccessUrl, ...urls.filter((url) => url !== lastSuccessUrl)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearStaleConfigSuccessUrl(url: string) {
|
function clearStaleConfigSuccessUrl(url: string) {
|
||||||
@@ -36,6 +57,22 @@ function clearStaleConfigSuccessUrl(url: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** پاککردن کامل کش کانفیگ قدیمی (کوکی + localStorage) */
|
||||||
|
function purgeLegacyConfigCache() {
|
||||||
|
clearCachedAppConfig()
|
||||||
|
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
|
||||||
|
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
|
||||||
|
window.__APP_CONFIG__ = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidAppConfig(config: AppConfig, url: string): AppConfig {
|
||||||
|
const user = (config as { user?: unknown })?.user
|
||||||
|
if (!user || typeof user !== 'object') {
|
||||||
|
throw new Error(`SuperAdmin config invalid (no user) from ${url}`)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
|
async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
|
const timeoutId = window.setTimeout(() => controller.abort(), timeout)
|
||||||
@@ -44,10 +81,10 @@ async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json'
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: controller.signal
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -60,14 +97,17 @@ async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
|
|||||||
throw new Error('Config response is not JSON')
|
throw new Error('Config response is not JSON')
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json()
|
return assertValidAppConfig(await response.json(), url)
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFreshAppConfig(rounds = CONFIG_RETRY_ROUNDS): Promise<AppConfig> {
|
async function tryFetchFromUrls(
|
||||||
const urls = sortConfigUrls(getConfigUrls())
|
urls: string[],
|
||||||
|
rounds: number,
|
||||||
|
): Promise<AppConfig | null> {
|
||||||
|
if (!urls.length) return null
|
||||||
|
|
||||||
for (let round = 1; round <= rounds; round++) {
|
for (let round = 1; round <= rounds; round++) {
|
||||||
for (let index = 0; index < urls.length; index++) {
|
for (let index = 0; index < urls.length; index++) {
|
||||||
@@ -77,9 +117,13 @@ export async function fetchFreshAppConfig(rounds = CONFIG_RETRY_ROUNDS): Promise
|
|||||||
const timeout = index === urls.length - 1 ? 7000 : 4000
|
const timeout = index === urls.length - 1 ? 7000 : 4000
|
||||||
const config = await fetchConfig(url, timeout)
|
const config = await fetchConfig(url, timeout)
|
||||||
|
|
||||||
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
|
if (isSuperAdminUrl(url)) {
|
||||||
|
localStorage.setItem(CONFIG_SUCCESS_URL_KEY, url)
|
||||||
|
} else {
|
||||||
|
clearStaleConfigSuccessUrl(url)
|
||||||
|
}
|
||||||
writeCachedAppConfig(config)
|
writeCachedAppConfig(config)
|
||||||
|
console.info('[config] loaded from SuperAdmin:', url, (config as { user?: { name?: string } })?.user?.name)
|
||||||
return config
|
return config
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearStaleConfigSuccessUrl(url)
|
clearStaleConfigSuccessUrl(url)
|
||||||
@@ -88,15 +132,157 @@ export async function fetchFreshAppConfig(rounds = CONFIG_RETRY_ROUNDS): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Cannot load app config from any server')
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAppConfig(): Promise<AppConfig> {
|
export async function fetchFreshAppConfig(
|
||||||
const cachedConfig = readCachedAppConfig()
|
rounds = CONFIG_RETRY_ROUNDS,
|
||||||
|
options?: { skipPurge?: boolean },
|
||||||
if (cachedConfig) {
|
): Promise<AppConfig> {
|
||||||
return cachedConfig
|
// cold load: کش قدیمی را دور بریز — poll/background نباید کش قطع را پاک کند
|
||||||
|
if (!options?.skipPurge) {
|
||||||
|
purgeLegacyConfigCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
return await fetchFreshAppConfig()
|
const urls = sortConfigUrls(getConfigUrls())
|
||||||
|
const saUrls = urls.filter(isSuperAdminUrl)
|
||||||
|
const otherUrls = urls.filter((url) => !isSuperAdminUrl(url))
|
||||||
|
|
||||||
|
// اول SA؛ در DEV اگر allowSuperAdminFallback فعال باشد به رجیستری میرود (در boursecu غیرفعال)
|
||||||
|
const lists =
|
||||||
|
saUrls.length > 0
|
||||||
|
? allowSuperAdminFallback()
|
||||||
|
? [saUrls, otherUrls]
|
||||||
|
: [saUrls]
|
||||||
|
: [urls]
|
||||||
|
|
||||||
|
console.info('[config] SuperAdmin-only URLs:', saUrls.length ? saUrls : urls)
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
const config = await tryFetchFromUrls(list, rounds)
|
||||||
|
if (config) return config
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Cannot load from SuperAdmin (${getSuperAdminBase() || 'none'}). دامنه tenant در سوپرادمین ثبت نشده یا API/CORS مشکل دارد.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNBLOCK_POLL_MS = 5000
|
||||||
|
let unblockPollTimer: number | null = null
|
||||||
|
|
||||||
|
function stopUnblockPoll() {
|
||||||
|
if (unblockPollTimer != null) {
|
||||||
|
window.clearInterval(unblockPollTimer)
|
||||||
|
unblockPollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistFreshConfig(config: AppConfig, domain: string, remoteVersion = '') {
|
||||||
|
writeCachedAppConfig(config)
|
||||||
|
if (remoteVersion) {
|
||||||
|
writeVersionedAppConfigCache(config, remoteVersion, domain)
|
||||||
|
writeStoredAppVersion(remoteVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** وقتی overlay قطع فعال است، وصل شدن از SA را بدون پاککردن کش چک کن */
|
||||||
|
function startUnblockPoll(domain: string) {
|
||||||
|
if (unblockPollTimer != null) return
|
||||||
|
|
||||||
|
unblockPollTimer = window.setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const fresh = await fetchFreshAppConfig(2, { skipPurge: true })
|
||||||
|
if (isServerUnreachableConfig(fresh)) return
|
||||||
|
|
||||||
|
let remoteVersion = ''
|
||||||
|
try {
|
||||||
|
remoteVersion = getNormalizedVersion(await loadRemoteAppVersion({ rounds: 2 }))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
stopUnblockPoll()
|
||||||
|
persistFreshConfig(fresh, domain, remoteVersion)
|
||||||
|
hideServerUnavailableOverlay()
|
||||||
|
window.location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unblock poll get_user failed:', error)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, UNBLOCK_POLL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockForBlockedServer(config: AppConfig, domain: string, remoteVersion = ''): never {
|
||||||
|
persistFreshConfig(config, domain, remoteVersion)
|
||||||
|
startUnblockPoll(domain)
|
||||||
|
assertServerReachableConfig(config)
|
||||||
|
throw new Error('unreachable')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اول get_version (سبک) — فقط اگر ورژن عوض شده یا کش نبود get_user میزند.
|
||||||
|
* اگر کش «قطع» باشد همیشه get_user تازه میزنیم تا وصل مجدد گیر نکند.
|
||||||
|
*/
|
||||||
|
export async function loadAppConfig(): Promise<AppConfig> {
|
||||||
|
const domain = resolveTenantDomain()
|
||||||
|
|
||||||
|
let remoteVersion = ''
|
||||||
|
try {
|
||||||
|
remoteVersion = getNormalizedVersion(await loadRemoteAppVersion({ rounds: 3 }))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('get_version failed during config load:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = readVersionedAppConfigCache(domain)
|
||||||
|
|
||||||
|
if (cached && remoteVersion && cached.version === remoteVersion) {
|
||||||
|
const config = cached.config
|
||||||
|
if (!isServerUnreachableConfig(config)) {
|
||||||
|
writeStoredAppVersion(remoteVersion)
|
||||||
|
// عمداً get_user در بکگراند نیست — فقط با عوض شدن ورژن
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
// کش قطع است با ورژن یکسان → fall through به fetch تازه
|
||||||
|
}
|
||||||
|
|
||||||
|
// اگر get_version در دسترس نبود و کش سالم است، از کش استفاده کن
|
||||||
|
if (cached && !remoteVersion) {
|
||||||
|
console.warn('Using cached get_user because get_version is unavailable')
|
||||||
|
const config = cached.config
|
||||||
|
if (!isServerUnreachableConfig(config)) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// سازگاری قدیمی بدون SA: کوکی کوچک
|
||||||
|
if (!getSuperAdminBase() && !cached) {
|
||||||
|
const cookieCached = readCachedAppConfig()
|
||||||
|
if (cookieCached && !remoteVersion && !isServerUnreachableConfig(cookieCached)) {
|
||||||
|
return cookieCached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await fetchFreshAppConfig()
|
||||||
|
if (isServerUnreachableConfig(config)) {
|
||||||
|
lockForBlockedServer(config, domain, remoteVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopUnblockPoll()
|
||||||
|
if (remoteVersion) {
|
||||||
|
writeVersionedAppConfigCache(config, remoteVersion, domain)
|
||||||
|
writeStoredAppVersion(remoteVersion)
|
||||||
|
} else {
|
||||||
|
writeCachedAppConfig(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
} catch (error) {
|
||||||
|
if (cached && isServerUnreachableConfig(cached.config)) {
|
||||||
|
lockForBlockedServer(cached.config, domain, remoteVersion)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-42
@@ -1,3 +1,11 @@
|
|||||||
|
import {
|
||||||
|
allowSuperAdminFallback,
|
||||||
|
getSuperAdminBases,
|
||||||
|
isSuperAdminUrl,
|
||||||
|
resolveTenantDomain,
|
||||||
|
SA_RETRY_ROUNDS,
|
||||||
|
} from '@/config/superadmin'
|
||||||
|
|
||||||
type VersionResponse =
|
type VersionResponse =
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
@@ -9,36 +17,25 @@ type VersionResponse =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
|
||||||
export const VERSION_RETRY_ROUNDS = 5
|
export const VERSION_RETRY_ROUNDS = SA_RETRY_ROUNDS
|
||||||
|
|
||||||
let versionFetchPromise: Promise<string | null> | null = null
|
let versionFetchPromise: Promise<string | null> | null = null
|
||||||
let cachedVersionResult: string | null | undefined
|
|
||||||
|
|
||||||
function getProtocol() {
|
|
||||||
return window.location.protocol === 'https:' ? 'https:' : 'http:'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** فقط SuperAdmin — بدون رجیستری قدیمی */
|
||||||
function getVersionUrls() {
|
function getVersionUrls() {
|
||||||
const protocol = getProtocol()
|
const domain = encodeURIComponent(resolveTenantDomain())
|
||||||
|
return getSuperAdminBases().map((base) => `${base}/get_version?domain=${domain}`)
|
||||||
return [
|
|
||||||
'https://nerkh.gold/get_version',
|
|
||||||
'https://tablo.talasevin.ir/get_version',
|
|
||||||
'https://abshode-service.montgit.ir/server/get_version',
|
|
||||||
`${protocol}//212.23.201.173:8381/get_version`,
|
|
||||||
`${protocol}//91.212.174.137:4040/server/get_version`
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortVersionUrls(urls: string[]) {
|
function sortVersionUrls(urls: string[]) {
|
||||||
const lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
|
const lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
|
||||||
|
if (!lastSuccessUrl || !urls.includes(lastSuccessUrl) || !isSuperAdminUrl(lastSuccessUrl)) {
|
||||||
if (!lastSuccessUrl) {
|
if (lastSuccessUrl && !isSuperAdminUrl(lastSuccessUrl)) {
|
||||||
return urls
|
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
|
||||||
|
}
|
||||||
|
return [...urls]
|
||||||
}
|
}
|
||||||
|
return [lastSuccessUrl, ...urls.filter((url) => url !== lastSuccessUrl)]
|
||||||
const others = urls.filter((url) => url !== lastSuccessUrl)
|
|
||||||
return [lastSuccessUrl, ...others]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearStaleVersionSuccessUrl(url: string) {
|
function clearStaleVersionSuccessUrl(url: string) {
|
||||||
@@ -89,10 +86,10 @@ async function fetchVersion(url: string, timeout = 3500): Promise<string | null>
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json'
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: controller.signal
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -100,10 +97,11 @@ async function fetchVersion(url: string, timeout = 3500): Promise<string | null>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') ?? ''
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
throw new Error('Version response is not JSON')
|
||||||
|
}
|
||||||
|
|
||||||
const data = contentType.includes('application/json')
|
const data = (await response.json()) as VersionResponse
|
||||||
? ((await response.json()) as VersionResponse)
|
|
||||||
: ((await response.text()) as VersionResponse)
|
|
||||||
|
|
||||||
const version = normalizeVersionResponse(data)
|
const version = normalizeVersionResponse(data)
|
||||||
|
|
||||||
@@ -112,15 +110,17 @@ async function fetchVersion(url: string, timeout = 3500): Promise<string | null>
|
|||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
|
localStorage.setItem(VERSION_SUCCESS_URL_KEY, url)
|
||||||
|
|
||||||
return version
|
return version
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeoutId)
|
window.clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<string | null> {
|
async function tryFetchVersionFromUrls(
|
||||||
const urls = sortVersionUrls(getVersionUrls())
|
urls: string[],
|
||||||
|
rounds: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!urls.length) return null
|
||||||
|
|
||||||
for (let round = 1; round <= rounds; round++) {
|
for (let round = 1; round <= rounds; round++) {
|
||||||
for (let index = 0; index < urls.length; index++) {
|
for (let index = 0; index < urls.length; index++) {
|
||||||
@@ -142,8 +142,27 @@ async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<str
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<string | null> {
|
||||||
|
const urls = sortVersionUrls(getVersionUrls())
|
||||||
|
const saUrls = urls.filter(isSuperAdminUrl)
|
||||||
|
const otherUrls = urls.filter((url) => !isSuperAdminUrl(url))
|
||||||
|
|
||||||
|
const lists =
|
||||||
|
saUrls.length > 0
|
||||||
|
? allowSuperAdminFallback()
|
||||||
|
? [saUrls, otherUrls]
|
||||||
|
: [saUrls]
|
||||||
|
: [urls]
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
const version = await tryFetchVersionFromUrls(list, rounds)
|
||||||
|
if (version) return version
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function resetVersionCheckCache() {
|
export function resetVersionCheckCache() {
|
||||||
cachedVersionResult = undefined
|
|
||||||
versionFetchPromise = null
|
versionFetchPromise = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,22 +177,13 @@ export async function loadRemoteAppVersion(options?: {
|
|||||||
resetVersionCheckCache()
|
resetVersionCheckCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cachedVersionResult !== undefined) {
|
|
||||||
return cachedVersionResult
|
|
||||||
}
|
|
||||||
|
|
||||||
if (versionFetchPromise) {
|
if (versionFetchPromise) {
|
||||||
return versionFetchPromise
|
return versionFetchPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
versionFetchPromise = fetchVersionWithRetry(rounds)
|
versionFetchPromise = fetchVersionWithRetry(rounds).finally(() => {
|
||||||
.then((version) => {
|
versionFetchPromise = null
|
||||||
cachedVersionResult = version
|
})
|
||||||
return version
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
versionFetchPromise = null
|
|
||||||
})
|
|
||||||
|
|
||||||
return versionFetchPromise
|
return versionFetchPromise
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/** سوپرادمین — مستر، بعد دو اسلیو */
|
||||||
|
export const SUPERADMIN_BASES = [
|
||||||
|
'https://adm.montzar.ir',
|
||||||
|
'https://adm.montech.ir',
|
||||||
|
'https://adm.montest.ir',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** هر API: سه دامنه × سه دور */
|
||||||
|
export const SA_RETRY_ROUNDS = 3
|
||||||
|
|
||||||
|
/** تنظیمات پیشرفته نسخه — همان دادهٔ /get_settings بدون نیاز به nginx جدا */
|
||||||
|
export const SA_PROGRAM_SETTINGS_PATH = '/api/public/program-settings'
|
||||||
|
|
||||||
|
const PRODUCTION_SA = SUPERADMIN_BASES[0]
|
||||||
|
/** پروکسی Vite در توسعه — بدون مشکل CORS */
|
||||||
|
const DEV_SA_PROXY = '/__sa'
|
||||||
|
|
||||||
|
function isLoopbackBase(url: string) {
|
||||||
|
try {
|
||||||
|
const host = new URL(url, 'http://localhost').hostname
|
||||||
|
return host === 'localhost' || host === '127.0.0.1'
|
||||||
|
} catch {
|
||||||
|
return /localhost|127\.0\.0\.1/i.test(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDevProxyBase(url: string) {
|
||||||
|
return url === DEV_SA_PROXY || url.startsWith(`${DEV_SA_PROXY}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBase(url: string) {
|
||||||
|
return String(url || '').trim().replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همه پایههای سوپرادمین به ترتیب اولویت (env اول، بعد لیست) */
|
||||||
|
export function getSuperAdminBases(): string[] {
|
||||||
|
const fromEnv = normalizeBase(String(import.meta.env.VITE_SUPERADMIN_URL || ''))
|
||||||
|
const bases = [...SUPERADMIN_BASES]
|
||||||
|
|
||||||
|
// در DEV همیشه اول پروکسی Vite — بدون CORS؛ بعد failover به دامنههای SA
|
||||||
|
if (import.meta.env.DEV && !fromEnv) {
|
||||||
|
return [DEV_SA_PROXY, ...bases]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromEnv) {
|
||||||
|
return [fromEnv, ...bases.filter((base) => base !== fromEnv)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return bases
|
||||||
|
}
|
||||||
|
|
||||||
|
/** پایه سوپرادمین اول لیست — برای سازگاری با کد قبلی */
|
||||||
|
export function getSuperAdminBase(): string {
|
||||||
|
return getSuperAdminBases()[0] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* دامنه tenant برای get_user:
|
||||||
|
* - VITE_TENANT_DOMAIN اگر ست باشد
|
||||||
|
* - در DEV روی localhost → دمو خانگی
|
||||||
|
* - در DEV روی IP/hostname LAN → همان host (برای domains[] در سوپرادمین)
|
||||||
|
* - در prod → hostname
|
||||||
|
*/
|
||||||
|
export function resolveTenantDomain(): string {
|
||||||
|
const override = String(import.meta.env.VITE_TENANT_DOMAIN || '').trim()
|
||||||
|
if (override) return override
|
||||||
|
|
||||||
|
const host = String(window.location.hostname || '').trim()
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
if (!host || host === 'localhost' || host === '127.0.0.1') {
|
||||||
|
return 'k.montzar.ir'
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
return host || 'k.montzar.ir'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuperAdminUrl(url: string): boolean {
|
||||||
|
return getSuperAdminBases().some((base) => url.startsWith(base))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** boursecu: فقط SuperAdmin — fallback به رجیستری غیرفعال (modular در DEV=true) */
|
||||||
|
export function allowSuperAdminFallback(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آدرس SA جایگزین وقتی primary لوکال/پروکسی است */
|
||||||
|
export function getFallbackSuperAdminBase(): string {
|
||||||
|
const primary = getSuperAdminBase()
|
||||||
|
if (!primary) return PRODUCTION_SA
|
||||||
|
if (isLoopbackBase(primary) || isDevProxyBase(primary)) return PRODUCTION_SA
|
||||||
|
const next = SUPERADMIN_BASES.find((base) => base !== primary)
|
||||||
|
return next || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSuperAdminJson<T>(
|
||||||
|
buildUrl: (base: string) => string,
|
||||||
|
timeout = 5000,
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: unknown
|
||||||
|
const bases = getSuperAdminBases()
|
||||||
|
for (let round = 1; round <= SA_RETRY_ROUNDS; round++) {
|
||||||
|
for (const base of bases) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = window.setTimeout(() => controller.abort(), timeout)
|
||||||
|
try {
|
||||||
|
const res = await fetch(buildUrl(base), {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
const contentType = res.headers.get('content-type') || ''
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
throw new Error('SuperAdmin response is not JSON')
|
||||||
|
}
|
||||||
|
return (await res.json()) as T
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error('SuperAdmin unreachable')
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import { computed } from 'vue'
|
|||||||
import { useConfigStore } from '@/store/configStore'
|
import { useConfigStore } from '@/store/configStore'
|
||||||
import { useSettingsStore } from '@/store/settingsStore'
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
import { getProjectSplashLogo } from '@/utils/config'
|
import { getProjectSplashLogo } from '@/utils/config'
|
||||||
|
import { featureSettingForRoute } from '@/utils/superAdminMenuGates'
|
||||||
import { getPrimaryNavItems, SECONDARY_NAV } from '../config/navigation'
|
import { getPrimaryNavItems, SECONDARY_NAV } from '../config/navigation'
|
||||||
import { useAppNavigation } from '../composables/useAppNavigation'
|
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||||
import DesktopNavItem from './DesktopNavItem.vue'
|
import DesktopNavItem from './DesktopNavItem.vue'
|
||||||
@@ -55,12 +56,19 @@ import AppCreditLink from '@/components/AppCreditLink.vue'
|
|||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const navLogo = computed(() => getProjectSplashLogo(config.config?.avatar))
|
const navLogo = computed(() => getProjectSplashLogo(config.config?.avatar))
|
||||||
const primaryItems = getPrimaryNavItems()
|
|
||||||
|
const primaryItems = computed(() =>
|
||||||
|
getPrimaryNavItems().filter((item) => {
|
||||||
|
const featureKey = featureSettingForRoute(item.routeName)
|
||||||
|
if (featureKey) return settingsStore.isSettingTruthy(featureKey)
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const secondaryItems = computed(() =>
|
const secondaryItems = computed(() =>
|
||||||
SECONDARY_NAV.filter((item) => {
|
SECONDARY_NAV.filter((item) => {
|
||||||
if (item.id === 'notifyTrigger') {
|
const featureKey = featureSettingForRoute(item.routeName)
|
||||||
return settingsStore.isSettingTruthy('has_page_price_trigger')
|
if (featureKey) return settingsStore.isSettingTruthy(featureKey)
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-1
@@ -3,9 +3,10 @@ import './assets/css/main.less'
|
|||||||
import 'virtual:uno.css'
|
import 'virtual:uno.css'
|
||||||
import '@unocss/reset/tailwind-compat.css'
|
import '@unocss/reset/tailwind-compat.css'
|
||||||
|
|
||||||
import { initPwaInstallCapture } from '@/modules/pwa/manifest'
|
import { applyCachedPwaChromeEarly, initPwaInstallCapture } from '@/modules/pwa/manifest'
|
||||||
import { initPwaInstallPromptListener } from '@/modules/pwa/pwaInstall'
|
import { initPwaInstallPromptListener } from '@/modules/pwa/pwaInstall'
|
||||||
import { initSafeAreaInsets } from '@/utils/safeAreaInsets'
|
import { initSafeAreaInsets } from '@/utils/safeAreaInsets'
|
||||||
|
import { preloadLottieAssets } from '@/utils/lottieAssets'
|
||||||
import { bootstrapApp } from '@/app/bootstrap'
|
import { bootstrapApp } from '@/app/bootstrap'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -16,5 +17,7 @@ export {
|
|||||||
|
|
||||||
initSafeAreaInsets()
|
initSafeAreaInsets()
|
||||||
initPwaInstallCapture()
|
initPwaInstallCapture()
|
||||||
|
applyCachedPwaChromeEarly()
|
||||||
initPwaInstallPromptListener()
|
initPwaInstallPromptListener()
|
||||||
|
void preloadLottieAssets()
|
||||||
void bootstrapApp()
|
void bootstrapApp()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export interface PriceTrigger {
|
export interface PriceTrigger {
|
||||||
id?: number
|
id?: number
|
||||||
|
product_id?: number
|
||||||
|
product?: { id: number; title?: string }
|
||||||
target_price: string
|
target_price: string
|
||||||
price_condition: string
|
price_condition: string
|
||||||
action: string
|
action: string
|
||||||
|
|||||||
@@ -31,4 +31,8 @@ export interface Price {
|
|||||||
time?: string
|
time?: string
|
||||||
main_product?: number
|
main_product?: number
|
||||||
montzar?: number
|
montzar?: number
|
||||||
|
zaarbox?: number
|
||||||
|
/** montzar | zaarbox | main_product | store = مظنه زنده، database_last = آخرین قیمت ذخیرهشده */
|
||||||
|
price_source?: string | null
|
||||||
|
last_historical?: number
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface Product {
|
|||||||
active: boolean
|
active: boolean
|
||||||
show: boolean
|
show: boolean
|
||||||
show_in_assets?: boolean
|
show_in_assets?: boolean
|
||||||
|
show_on_tv?: boolean
|
||||||
open_sell: boolean
|
open_sell: boolean
|
||||||
open_buy: boolean
|
open_buy: boolean
|
||||||
group_type:string
|
group_type:string
|
||||||
@@ -28,6 +29,8 @@ export interface Product {
|
|||||||
has_physical_delivery?: boolean
|
has_physical_delivery?: boolean
|
||||||
min_physical_delivery_quantity?: number | null
|
min_physical_delivery_quantity?: number | null
|
||||||
direct_price: number | undefined
|
direct_price: number | undefined
|
||||||
|
price_formula_enabled?: boolean | number | null
|
||||||
|
price_formula?: string | null
|
||||||
price_offset_percent?: number | null
|
price_offset_percent?: number | null
|
||||||
use_last_price_if_zero?: boolean
|
use_last_price_if_zero?: boolean
|
||||||
reference_provider_active?: boolean
|
reference_provider_active?: boolean
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface Transaction {
|
|||||||
unit: string
|
unit: string
|
||||||
hash?: string
|
hash?: string
|
||||||
id?: number
|
id?: number
|
||||||
note?: null
|
note?: string | null
|
||||||
title: string
|
title: string
|
||||||
order_id?: number
|
order_id?: number
|
||||||
transaction_type?: string
|
transaction_type?: string
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type PwaManifest = {
|
|||||||
|
|
||||||
const MANIFEST_CACHE_KEY = 'PWA_MANIFEST_CACHE_V5'
|
const MANIFEST_CACHE_KEY = 'PWA_MANIFEST_CACHE_V5'
|
||||||
const MANIFEST_CACHE_MAX_AGE_MS = 60 * 60 * 24 * 7
|
const MANIFEST_CACHE_MAX_AGE_MS = 60 * 60 * 24 * 7
|
||||||
|
/** با اسکریپت inline در index.html هماهنگ بماند */
|
||||||
|
export const PWA_INSTALL_ICON_URL_KEY = 'PWA_INSTALL_ICON_URL'
|
||||||
|
|
||||||
export type CachedPwaManifest = {
|
export type CachedPwaManifest = {
|
||||||
cached_at: number
|
cached_at: number
|
||||||
@@ -69,4 +71,39 @@ export function writeCachedPwaManifest(entry: CachedPwaManifest) {
|
|||||||
|
|
||||||
export function clearCachedPwaManifest() {
|
export function clearCachedPwaManifest() {
|
||||||
localStorage.removeItem(MANIFEST_CACHE_KEY)
|
localStorage.removeItem(MANIFEST_CACHE_KEY)
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(PWA_INSTALL_ICON_URL_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCachedPwaInstallIconUrl(src: string) {
|
||||||
|
try {
|
||||||
|
const value = String(src || '').trim()
|
||||||
|
if (!value) return
|
||||||
|
localStorage.setItem(PWA_INSTALL_ICON_URL_KEY, value)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** برای گرمکردن آیکون نصب، بدون نیاز به userId / configBase */
|
||||||
|
export function readAnyFreshCachedPwaManifest(): CachedPwaManifest | 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 (!Array.isArray(payload.manifest.icons) || payload.manifest.icons.length === 0) return null
|
||||||
|
|
||||||
|
if (Date.now() - Number(payload.cached_at || 0) > MANIFEST_CACHE_MAX_AGE_MS) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-12
@@ -1,9 +1,16 @@
|
|||||||
import type { AppConfig } from '@/config/types'
|
import type { AppConfig } from '@/config/types'
|
||||||
import { getConfig, getRawAppConfig } from '@/config/app-config'
|
import { getConfig, getRawAppConfig } from '@/config/app-config'
|
||||||
import { getConfigUrls, sortConfigUrls } from '@/config/load-config'
|
import { getConfigUrls, sortConfigUrls } from '@/config/load-config'
|
||||||
|
import {
|
||||||
|
getSuperAdminBase,
|
||||||
|
isSuperAdminUrl,
|
||||||
|
resolveTenantDomain,
|
||||||
|
} from '@/config/superadmin'
|
||||||
import {
|
import {
|
||||||
clearCachedPwaManifest,
|
clearCachedPwaManifest,
|
||||||
|
readAnyFreshCachedPwaManifest,
|
||||||
readCachedPwaManifest,
|
readCachedPwaManifest,
|
||||||
|
writeCachedPwaInstallIconUrl,
|
||||||
writeCachedPwaManifest,
|
writeCachedPwaManifest,
|
||||||
type PwaManifest
|
type PwaManifest
|
||||||
} from '@/modules/pwa/manifest-cache'
|
} from '@/modules/pwa/manifest-cache'
|
||||||
@@ -22,8 +29,26 @@ function sleep(ms: number) {
|
|||||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** پایهٔ سرور از URLهای get_user یا manifest — query مثل ?domain= را حذف میکند */
|
||||||
function normalizeBaseUrl(url: string) {
|
function normalizeBaseUrl(url: string) {
|
||||||
return url.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
parsed.search = ''
|
||||||
|
parsed.hash = ''
|
||||||
|
parsed.pathname = parsed.pathname
|
||||||
|
.replace(/\/get_user\/?$/i, '')
|
||||||
|
.replace(/\/manifest\.json$/i, '')
|
||||||
|
if (!parsed.pathname || parsed.pathname === '/') {
|
||||||
|
return parsed.origin
|
||||||
|
}
|
||||||
|
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
.replace(/[?#].*$/, '')
|
||||||
|
.replace(/\/get_user\/?$/i, '')
|
||||||
|
.replace(/\/manifest\.json$/i, '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** همان baseای که get_user از آن لود شده (کش شده در localStorage) */
|
/** همان baseای که get_user از آن لود شده (کش شده در localStorage) */
|
||||||
@@ -33,15 +58,31 @@ export function getConfigBaseFromStorage() {
|
|||||||
return normalizeBaseUrl(lastSuccess)
|
return normalizeBaseUrl(lastSuccess)
|
||||||
}
|
}
|
||||||
|
|
||||||
function configUrlToManifestUrl(configUrl: string) {
|
function getSuperAdminManifestUrl(): string | null {
|
||||||
return `${configUrl.replace(/\/+$/, '')}/manifest.json`
|
const saBase = getSuperAdminBase()
|
||||||
|
if (!saBase) return null
|
||||||
|
return `${saBase}/manifest.json?domain=${encodeURIComponent(resolveTenantDomain())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** همان ۵ URL پایهٔ get_user — اولویت با CONFIG_Success_URL */
|
function configUrlToManifestUrl(configUrl: string) {
|
||||||
|
if (isSuperAdminUrl(configUrl)) {
|
||||||
|
return getSuperAdminManifestUrl() || `${normalizeBaseUrl(configUrl)}/manifest.json`
|
||||||
|
}
|
||||||
|
return `${normalizeBaseUrl(configUrl)}/manifest.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اولویت: manifest_url صریح → سوپرادمین (با domain) → رجیستریهای قدیمی
|
||||||
|
*/
|
||||||
export function resolveManifestUrls(config?: AppConfig | null): string[] {
|
export function resolveManifestUrls(config?: AppConfig | null): string[] {
|
||||||
const explicit = getConfig<string>('manifest_url')
|
const explicit = getConfig<string>('manifest_url')
|
||||||
if (explicit) return [explicit]
|
if (explicit) return [explicit]
|
||||||
|
|
||||||
|
const saManifest = getSuperAdminManifestUrl()
|
||||||
|
if (saManifest) {
|
||||||
|
return [saManifest]
|
||||||
|
}
|
||||||
|
|
||||||
return sortConfigUrls(getConfigUrls()).map(configUrlToManifestUrl)
|
return sortConfigUrls(getConfigUrls()).map(configUrlToManifestUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,8 +263,12 @@ function mapManifestIcons(manifest: PwaManifest, base: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** فقط آیکونهای داخل monifist.json — بدون آواتار / pwa-icons / env */
|
/** فقط آیکونهای داخل monifist.json — بدون آواتار / pwa-icons / env */
|
||||||
function resolveInstallIcons(manifest: PwaManifest) {
|
function resolveInstallIcons(manifest: PwaManifest, manifestUrl?: string) {
|
||||||
const base = getConfigBaseFromStorage()
|
const lastSuccess = localStorage.getItem(CONFIG_SUCCESS_URL_KEY) || ''
|
||||||
|
const fromManifest = manifestUrl ? normalizeBaseUrl(manifestUrl) : ''
|
||||||
|
const fromConfig =
|
||||||
|
lastSuccess && !isSuperAdminUrl(lastSuccess) ? getConfigBaseFromStorage() : ''
|
||||||
|
const base = fromManifest || fromConfig
|
||||||
const mapped = mapManifestIcons(manifest, base)
|
const mapped = mapManifestIcons(manifest, base)
|
||||||
|
|
||||||
if (!mapped.length) {
|
if (!mapped.length) {
|
||||||
@@ -235,10 +280,10 @@ function resolveInstallIcons(manifest: PwaManifest) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
function prepareManifestForInstall(manifest: PwaManifest) {
|
function prepareManifestForInstall(manifest: PwaManifest, manifestUrl?: string) {
|
||||||
const scopeUrl = toSameOriginManifestUrl(manifest.scope ?? manifest.start_url, '/')
|
const scopeUrl = toSameOriginManifestUrl(manifest.scope ?? manifest.start_url, '/')
|
||||||
const startUrl = toSameOriginManifestUrl(manifest.start_url, '/')
|
const startUrl = toSameOriginManifestUrl(manifest.start_url, '/')
|
||||||
const icons = resolveInstallIcons(manifest)
|
const icons = resolveInstallIcons(manifest, manifestUrl)
|
||||||
const name = manifest.short_name || manifest.name
|
const name = manifest.short_name || manifest.name
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -288,6 +333,60 @@ function pickManifestIconUrl(manifest: PwaManifest, preferredSizes: number[]) {
|
|||||||
return sorted[0]?.src || ''
|
return sorted[0]?.src || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PWA_ICON_CACHE_NAME = 'pwa-install-icons-v1'
|
||||||
|
|
||||||
|
function uniqueIconSrcs(manifest: PwaManifest): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const urls: string[] = []
|
||||||
|
for (const icon of manifest.icons || []) {
|
||||||
|
const src = String(icon?.src || '').trim()
|
||||||
|
if (!src || seen.has(src)) continue
|
||||||
|
seen.add(src)
|
||||||
|
urls.push(src)
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
/** کش HTTP مرورگر — همان مسیری که شیت نصب iOS آیکون را میگیرد (نه blob) */
|
||||||
|
function warmIconInHttpCache(src: string) {
|
||||||
|
const img = new Image()
|
||||||
|
img.decoding = 'async'
|
||||||
|
img.src = src
|
||||||
|
void img.decode().catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistIconInCacheApi(src: string) {
|
||||||
|
if (typeof caches === 'undefined') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(PWA_ICON_CACHE_NAME)
|
||||||
|
if (await cache.match(src)) return
|
||||||
|
|
||||||
|
const fetched = await fetch(src, {
|
||||||
|
method: 'GET',
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
cache: 'force-cache',
|
||||||
|
priority: 'high',
|
||||||
|
} as RequestInit)
|
||||||
|
|
||||||
|
if (!fetched.ok) return
|
||||||
|
await cache.put(src, fetched)
|
||||||
|
} catch {
|
||||||
|
// بدون CORS یا سهمیه — Image() همچنان کش HTTP را گرم میکند
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function warmPwaInstallIcons(manifest: PwaManifest) {
|
||||||
|
const urls = uniqueIconSrcs(manifest)
|
||||||
|
if (!urls.length) return
|
||||||
|
|
||||||
|
urls.forEach((src) => {
|
||||||
|
warmIconInHttpCache(src)
|
||||||
|
void persistIconInCacheApi(src)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function upsertLink(rel: string, attrs: Record<string, string>) {
|
function upsertLink(rel: string, attrs: Record<string, string>) {
|
||||||
const selectorParts = Object.entries(attrs)
|
const selectorParts = Object.entries(attrs)
|
||||||
.filter(([key]) => key !== 'href')
|
.filter(([key]) => key !== 'href')
|
||||||
@@ -351,6 +450,7 @@ function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
|
|||||||
|
|
||||||
upsertLink('apple-touch-icon', {
|
upsertLink('apple-touch-icon', {
|
||||||
'data-dynamic-pwa': 'true',
|
'data-dynamic-pwa': 'true',
|
||||||
|
'data-pwa-icon-src': href,
|
||||||
sizes: `${size}x${size}`,
|
sizes: `${size}x${size}`,
|
||||||
href
|
href
|
||||||
})
|
})
|
||||||
@@ -359,8 +459,10 @@ function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
|
|||||||
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
|
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
|
||||||
|
|
||||||
if (primary) {
|
if (primary) {
|
||||||
|
writeCachedPwaInstallIconUrl(primary)
|
||||||
upsertLink('apple-touch-icon', {
|
upsertLink('apple-touch-icon', {
|
||||||
'data-dynamic-pwa': 'true',
|
'data-dynamic-pwa': 'true',
|
||||||
|
'data-pwa-icon-src': primary,
|
||||||
href: primary
|
href: primary
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -410,12 +512,14 @@ export async function applyPreparedManifestToDocument(
|
|||||||
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
|
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
|
||||||
|
|
||||||
if (favicon) {
|
if (favicon) {
|
||||||
upsertLink('icon', { type: 'image/png', href: favicon })
|
upsertLink('icon', { type: 'image/png', href: favicon, 'data-pwa-icon-src': favicon })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
warmPwaInstallIcons(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyManifestToDocument(manifest: PwaManifest, manifestUrl: string) {
|
export async function applyManifestToDocument(manifest: PwaManifest, manifestUrl: string) {
|
||||||
const prepared = prepareManifestForInstall(manifest)
|
const prepared = prepareManifestForInstall(manifest, manifestUrl)
|
||||||
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,6 +535,25 @@ export function isPwaManifestReady() {
|
|||||||
return sessionStorage.getItem(PWA_MANIFEST_READY_KEY) === '1'
|
return sessionStorage.getItem(PWA_MANIFEST_READY_KEY) === '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** کش مانیفست قبلی را همان اول بوت روی apple-touch-icon مینشاند و تصویر را گرم میکند */
|
||||||
|
export function applyCachedPwaChromeEarly() {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
||||||
|
|
||||||
|
const cached = readAnyFreshCachedPwaManifest()
|
||||||
|
if (!cached?.manifest) return
|
||||||
|
|
||||||
|
const apply = (manifest: PwaManifest) =>
|
||||||
|
applyPreparedManifestToDocument(manifest, cached.manifest_url).catch((error) => {
|
||||||
|
console.warn('Early PWA icon apply failed:', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
void apply(prepareManifestForInstall(cached.manifest, cached.manifest_url))
|
||||||
|
} catch {
|
||||||
|
void apply(cached.manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** هرچه زودتر در main.ts — قبل از bootstrap */
|
/** هرچه زودتر در main.ts — قبل از bootstrap */
|
||||||
export function initPwaInstallCapture() {
|
export function initPwaInstallCapture() {
|
||||||
if (installCaptureInitialized || typeof window === 'undefined') return
|
if (installCaptureInitialized || typeof window === 'undefined') return
|
||||||
@@ -476,7 +599,7 @@ async function applyCachedManifestEntry(
|
|||||||
if (!cached) return false
|
if (!cached) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prepared = prepareManifestForInstall(cached.manifest)
|
const prepared = prepareManifestForInstall(cached.manifest, cached.manifest_url)
|
||||||
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
|
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
|
||||||
setPwaManifestReady(true)
|
setPwaManifestReady(true)
|
||||||
return true
|
return true
|
||||||
@@ -514,7 +637,7 @@ export async function setupPwaManifest(config?: AppConfig | null): Promise<boole
|
|||||||
try {
|
try {
|
||||||
const { manifest, manifestUrl } = await fetchManifestTryingUrls(manifestUrls)
|
const { manifest, manifestUrl } = await fetchManifestTryingUrls(manifestUrls)
|
||||||
|
|
||||||
const prepared = prepareManifestForInstall(manifest)
|
const prepared = prepareManifestForInstall(manifest, manifestUrl)
|
||||||
|
|
||||||
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
await applyPreparedManifestToDocument(prepared, manifestUrl)
|
||||||
setPwaManifestReady(true)
|
setPwaManifestReady(true)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
cancelVersionUpdateLock,
|
cancelVersionUpdateLock,
|
||||||
hardReloadApp
|
hardReloadApp
|
||||||
} from '@/config/config-cache'
|
} from '@/config/config-cache'
|
||||||
|
import { assertServerReachableConfig, isServerUnreachableConfig, isServerUnavailableError } from '@/utils/serverUnavailable'
|
||||||
|
|
||||||
const isUpdating = ref(false)
|
const isUpdating = ref(false)
|
||||||
const hasError = ref(false)
|
const hasError = ref(false)
|
||||||
@@ -86,6 +87,11 @@ async function startUpdate(version: string) {
|
|||||||
|
|
||||||
const freshConfig = await refreshConfigCacheForVersion(version, fetchFreshAppConfig)
|
const freshConfig = await refreshConfigCacheForVersion(version, fetchFreshAppConfig)
|
||||||
|
|
||||||
|
if (isServerUnreachableConfig(freshConfig)) {
|
||||||
|
assertServerReachableConfig(freshConfig)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setAppConfig(freshConfig)
|
setAppConfig(freshConfig)
|
||||||
|
|
||||||
await wait(500)
|
await wait(500)
|
||||||
@@ -97,6 +103,11 @@ async function startUpdate(version: string) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Version update failed:', error)
|
console.error('Version update failed:', error)
|
||||||
|
|
||||||
|
if (isServerUnavailableError(error)) {
|
||||||
|
cancelVersionUpdateLock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (configBackup) {
|
if (configBackup) {
|
||||||
window.__APP_CONFIG__ = configBackup
|
window.__APP_CONFIG__ = configBackup
|
||||||
setAppConfig(configBackup)
|
setAppConfig(configBackup)
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ const router = createRouter({
|
|||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('../views/login/index.vue')
|
component: () => import('../views/login/index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: `/tv`,
|
||||||
|
name: 'tvMonitor',
|
||||||
|
alias: ['/monitor'],
|
||||||
|
meta: {
|
||||||
|
public: true,
|
||||||
|
hideChrome: true,
|
||||||
|
hideMenu: true,
|
||||||
|
},
|
||||||
|
component: () => import('../views/tv/index.vue')
|
||||||
|
},
|
||||||
/* {
|
/* {
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'null',
|
name: 'null',
|
||||||
@@ -117,6 +128,11 @@ const router = createRouter({
|
|||||||
name: 'giftCard',
|
name: 'giftCard',
|
||||||
component: () => import('@/views/base/giftCard/index.vue')
|
component: () => import('@/views/base/giftCard/index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'recoverySites',
|
||||||
|
name: 'recoverySites',
|
||||||
|
component: () => import('@/views/base/recoverySites/index.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'fund',
|
path: 'fund',
|
||||||
name: 'fund',
|
name: 'fund',
|
||||||
@@ -155,6 +171,11 @@ const router = createRouter({
|
|||||||
name: 'aboutUs',
|
name: 'aboutUs',
|
||||||
component: () => import('@/views/base/aboutUs/index.vue')
|
component: () => import('@/views/base/aboutUs/index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/about-software',
|
||||||
|
name: 'aboutSoftware',
|
||||||
|
component: () => import('@/views/base/aboutSoftware/index.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'notFound',
|
name: 'notFound',
|
||||||
|
|||||||
+36
-13
@@ -1,6 +1,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { getSettings } from '@/api/configuration'
|
import { getSettings } from '@/api/configuration'
|
||||||
|
import { fetchSuperAdminProgramSettings, isLockedCanEdit } from '@/api/superadminSettings'
|
||||||
import { resolveMeltedGoldLabel, resolveMetalTypeLabel } from '@/utils/metalLabels'
|
import { resolveMeltedGoldLabel, resolveMetalTypeLabel } from '@/utils/metalLabels'
|
||||||
import { resolveTradeWeightUnit } from '@/utils/weightUnit'
|
import { resolveTradeWeightUnit } from '@/utils/weightUnit'
|
||||||
import { runOnceAsync } from '@/composables/useOnceAsync'
|
import { runOnceAsync } from '@/composables/useOnceAsync'
|
||||||
@@ -13,11 +14,13 @@ export interface AppSetting {
|
|||||||
description?: string
|
description?: string
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
type?: string
|
type?: string
|
||||||
|
can_edit?: number | boolean | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
const settings = ref<AppSetting[]>([])
|
const settings = ref<AppSetting[]>([])
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
const settingsRevision = ref(0)
|
||||||
|
|
||||||
function getSettingByKey(key: string) {
|
function getSettingByKey(key: string) {
|
||||||
const item = settings.value.find((setting) => setting?.key === key)
|
const item = settings.value.find((setting) => setting?.key === key)
|
||||||
@@ -42,6 +45,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const merged = [...settings.value]
|
const merged = [...settings.value]
|
||||||
|
let changed = false
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
if (!item?.key) {
|
if (!item?.key) {
|
||||||
return
|
return
|
||||||
@@ -49,27 +53,37 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
|
|
||||||
const index = merged.findIndex((setting) => setting.key === item.key)
|
const index = merged.findIndex((setting) => setting.key === item.key)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
merged[index] = { ...merged[index], ...item }
|
const current = merged[index]
|
||||||
} else {
|
if (current.value === item.value && current.is_active === item.is_active) {
|
||||||
merged.push(item)
|
return
|
||||||
|
}
|
||||||
|
merged[index] = { ...current, ...item }
|
||||||
|
changed = true
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
merged.push(item)
|
||||||
|
changed = true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
settings.value = merged
|
settings.value = merged
|
||||||
|
settingsRevision.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSettings(force = false) {
|
async function fetchSettings(force = false) {
|
||||||
return runOnceAsync('settings:all', async () => {
|
return runOnceAsync('settings:all', async () => {
|
||||||
try {
|
const localRaw = await getSettings().catch(() => [])
|
||||||
const data = await getSettings()
|
const remote = await fetchSuperAdminProgramSettings()
|
||||||
if (Array.isArray(data)) {
|
const local = Array.isArray(localRaw) ? localRaw : []
|
||||||
mergeSettings(data)
|
const editable = local.filter((row) => !isLockedCanEdit(row?.can_edit))
|
||||||
}
|
settings.value = []
|
||||||
} catch (e) {
|
mergeSettings(remote)
|
||||||
console.log(e)
|
mergeSettings(editable)
|
||||||
} finally {
|
loaded.value = true
|
||||||
loaded.value = true
|
|
||||||
}
|
|
||||||
return settings.value
|
return settings.value
|
||||||
}, force)
|
}, force)
|
||||||
}
|
}
|
||||||
@@ -78,7 +92,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const metalTypeLabel = computed(() => resolveMetalTypeLabel(metalTypeSettingValue.value))
|
const metalTypeLabel = computed(() => resolveMetalTypeLabel(metalTypeSettingValue.value))
|
||||||
const meltedGoldLabel = computed(() => resolveMeltedGoldLabel(metalTypeSettingValue.value))
|
const meltedGoldLabel = computed(() => resolveMeltedGoldLabel(metalTypeSettingValue.value))
|
||||||
const tradeWeightUnit = computed(() => resolveTradeWeightUnit(getSettingByKey('unit')))
|
const tradeWeightUnit = computed(() => resolveTradeWeightUnit(getSettingByKey('unit')))
|
||||||
|
const isReferralFieldVisible = computed(() => {
|
||||||
|
// کلید نباشد → نمایش (رفتار قبلی)
|
||||||
|
if (!settings.value?.some((s) => s?.key === 'referral_show')) return true
|
||||||
|
return isSettingTruthy('referral_show')
|
||||||
|
})
|
||||||
|
|
||||||
const isReferralRequired = computed(() => {
|
const isReferralRequired = computed(() => {
|
||||||
|
if (!isReferralFieldVisible.value) return false
|
||||||
const value = getSettingByKey('referral_id')
|
const value = getSettingByKey('referral_id')
|
||||||
return String(value ?? '').trim().toLowerCase() === 'required'
|
return String(value ?? '').trim().toLowerCase() === 'required'
|
||||||
})
|
})
|
||||||
@@ -86,11 +107,13 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
loaded,
|
loaded,
|
||||||
|
settingsRevision,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
mergeSettings,
|
mergeSettings,
|
||||||
getSettingByKey,
|
getSettingByKey,
|
||||||
isSettingTruthy,
|
isSettingTruthy,
|
||||||
isReferralRequired,
|
isReferralRequired,
|
||||||
|
isReferralFieldVisible,
|
||||||
metalTypeSettingValue,
|
metalTypeSettingValue,
|
||||||
metalTypeLabel,
|
metalTypeLabel,
|
||||||
meltedGoldLabel,
|
meltedGoldLabel,
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function getValueByKey(keyName) {
|
function getValueByKey(keyName: string) {
|
||||||
const item = userInfo.value.settings?.find(obj => obj.key === keyName);
|
const item = userInfo.value?.settings?.find(obj => obj.key === keyName)
|
||||||
|
|
||||||
// اگر آیتم پیدا شد، فیلد value را برگردان، در غیر این صورت null
|
// اگر آیتم پیدا شد، فیلد value را برگردان، در غیر این صورت null
|
||||||
return item ? item.value : null;
|
return item ? item.value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeInformation() {
|
function removeInformation() {
|
||||||
|
|||||||
+3
-2
@@ -3,7 +3,7 @@ import { useWebSocket } from '@vueuse/core'
|
|||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
import { useSettingsStore } from '@/store/settingsStore'
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
import { startChat } from '@/api/chat'
|
import { startChat } from '@/api/chat'
|
||||||
import { createUuid } from '@/utils/index'
|
import { createUuid, getToken } from '@/utils/index'
|
||||||
|
|
||||||
/** true/1/yes/activate */
|
/** true/1/yes/activate */
|
||||||
export function isSettingFlagOn(value: unknown): boolean {
|
export function isSettingFlagOn(value: unknown): boolean {
|
||||||
@@ -11,8 +11,9 @@ export function isSettingFlagOn(value: unknown): boolean {
|
|||||||
return v === 'true' || v === '1' || v === 'yes' || v === 'activate'
|
return v === 'true' || v === '1' || v === 'yes' || v === 'activate'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** نمایش چت برای کاربر نهایی: سوییچ دیپلوی + سوییچ مشتری */
|
/** نمایش چت برای کاربر نهایی: لاگین + سوییچ دیپلوی + سوییچ مشتری */
|
||||||
export function isUserSupportChatEnabled(): boolean {
|
export function isUserSupportChatEnabled(): boolean {
|
||||||
|
if (!getToken()) return false
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
return (
|
return (
|
||||||
isSettingFlagOn(settings.getSettingByKey('show_to_users_chat')) &&
|
isSettingFlagOn(settings.getSettingByKey('show_to_users_chat')) &&
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useUserStore } from '@/store/userStore'
|
|||||||
export interface DepositGuideVars {
|
export interface DepositGuideVars {
|
||||||
accountHolder?: string | null
|
accountHolder?: string | null
|
||||||
cardNumber?: string | null
|
cardNumber?: string | null
|
||||||
|
accountNumber?: string | null
|
||||||
sheba?: string | null
|
sheba?: string | null
|
||||||
/** نام مشتری واردشده — برای پیام پشتیبانی واریز */
|
/** نام مشتری واردشده — برای پیام پشتیبانی واریز */
|
||||||
customerName?: string | null
|
customerName?: string | null
|
||||||
@@ -19,6 +20,7 @@ export const DEFAULT_DEPOSIT_SUPPORT_MESSAGE_TEXT =
|
|||||||
/** الگوی متغیر نام — ٪نام / %نام / % نام / %نام% */
|
/** الگوی متغیر نام — ٪نام / %نام / % نام / %نام% */
|
||||||
const NAME_PLACEHOLDER_RE = /[%٪]\s*نام(?:\s*[%٪])?/gu
|
const NAME_PLACEHOLDER_RE = /[%٪]\s*نام(?:\s*[%٪])?/gu
|
||||||
const CARD_PLACEHOLDER_RE = /[%٪]\s*کارت(?:\s*[%٪])?/gu
|
const CARD_PLACEHOLDER_RE = /[%٪]\s*کارت(?:\s*[%٪])?/gu
|
||||||
|
const ACCOUNT_PLACEHOLDER_RE = /[%٪]\s*حساب(?:\s*[%٪])?/gu
|
||||||
const SHEBA_PLACEHOLDER_RE = /[%٪]\s*شبا(?:\s*[%٪])?/gu
|
const SHEBA_PLACEHOLDER_RE = /[%٪]\s*شبا(?:\s*[%٪])?/gu
|
||||||
|
|
||||||
export function getCustomerNameFromUser(): string {
|
export function getCustomerNameFromUser(): string {
|
||||||
@@ -45,17 +47,19 @@ export function renderDepositSupportMessageText(
|
|||||||
return template.replace(NAME_PLACEHOLDER_RE, name)
|
return template.replace(NAME_PLACEHOLDER_RE, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** جایگزینی متغیرها در متن راهنمای واریز — ٪نام / %نام، ٪کارت / %کارت، ٪شبا / %شبا */
|
/** جایگزینی متغیرها در متن راهنمای واریز — ٪نام / %نام، ٪کارت / %کارت، ٪حساب / %حساب، ٪شبا / %شبا */
|
||||||
export function renderDepositGuideText(
|
export function renderDepositGuideText(
|
||||||
template: string,
|
template: string,
|
||||||
vars: DepositGuideVars,
|
vars: DepositGuideVars,
|
||||||
): string {
|
): string {
|
||||||
const accountHolder = String(vars.accountHolder ?? '').trim()
|
const accountHolder = String(vars.accountHolder ?? '').trim()
|
||||||
const cardNumber = String(vars.cardNumber ?? '').trim()
|
const cardNumber = String(vars.cardNumber ?? '').trim()
|
||||||
|
const accountNumber = String(vars.accountNumber ?? '').trim()
|
||||||
const sheba = String(vars.sheba ?? '').trim()
|
const sheba = String(vars.sheba ?? '').trim()
|
||||||
|
|
||||||
return template
|
return template
|
||||||
.replace(SHEBA_PLACEHOLDER_RE, sheba)
|
.replace(SHEBA_PLACEHOLDER_RE, sheba)
|
||||||
|
.replace(ACCOUNT_PLACEHOLDER_RE, accountNumber)
|
||||||
.replace(CARD_PLACEHOLDER_RE, cardNumber)
|
.replace(CARD_PLACEHOLDER_RE, cardNumber)
|
||||||
.replace(NAME_PLACEHOLDER_RE, accountHolder)
|
.replace(NAME_PLACEHOLDER_RE, accountHolder)
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,7 @@ export function getDepositGuideVarsFromSettings(): DepositGuideVars {
|
|||||||
return {
|
return {
|
||||||
accountHolder: store.getSettingByKey('deposit_account_holder'),
|
accountHolder: store.getSettingByKey('deposit_account_holder'),
|
||||||
cardNumber: store.getSettingByKey('deposit_card_number'),
|
cardNumber: store.getSettingByKey('deposit_card_number'),
|
||||||
|
accountNumber: store.getSettingByKey('deposit_account_number'),
|
||||||
sheba: store.getSettingByKey('deposit_sheba'),
|
sheba: store.getSettingByKey('deposit_sheba'),
|
||||||
customerName,
|
customerName,
|
||||||
}
|
}
|
||||||
@@ -115,6 +120,12 @@ export function formatDepositCardNumber(value?: string | null) {
|
|||||||
return digits.replace(/(\d{4})(?=\d)/g, '$1 ').trim()
|
return digits.replace(/(\d{4})(?=\d)/g, '$1 ').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDepositAccountNumber(value?: string | null) {
|
||||||
|
const digits = String(value ?? '').replace(/\D/g, '')
|
||||||
|
if (!digits) return String(value ?? '').trim()
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDepositSheba(value?: string | null) {
|
export function formatDepositSheba(value?: string | null) {
|
||||||
const normalized = String(value ?? '').replace(/\s/g, '').toUpperCase()
|
const normalized = String(value ?? '').replace(/\s/g, '').toUpperCase()
|
||||||
if (!normalized) return ''
|
if (!normalized) return ''
|
||||||
@@ -134,6 +145,10 @@ export function displayDepositCardNumber(value?: string | null) {
|
|||||||
return formatDepositCardNumber(value) || '**** **** **** ****'
|
return formatDepositCardNumber(value) || '**** **** **** ****'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function displayDepositAccountNumber(value?: string | null) {
|
||||||
|
return formatDepositAccountNumber(value) || '—'
|
||||||
|
}
|
||||||
|
|
||||||
export function displayDepositSheba(value?: string | null) {
|
export function displayDepositSheba(value?: string | null) {
|
||||||
return formatDepositSheba(value) || 'IR** **** **** **** **** **** **'
|
return formatDepositSheba(value) || 'IR** **** **** **** **** **** **'
|
||||||
}
|
}
|
||||||
@@ -149,7 +164,7 @@ export function getDepositSettingLabel(key: string, fallback: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDepositCopyValue(value: string, kind: 'card' | 'sheba') {
|
function normalizeDepositCopyValue(value: string, kind: 'card' | 'account' | 'sheba') {
|
||||||
const normalized = value.replace(/\s/g, '')
|
const normalized = value.replace(/\s/g, '')
|
||||||
if (!normalized) return ''
|
if (!normalized) return ''
|
||||||
|
|
||||||
@@ -160,12 +175,12 @@ function normalizeDepositCopyValue(value: string, kind: 'card' | 'sheba') {
|
|||||||
return digits ? `IR${digits}` : upper
|
return digits ? `IR${digits}` : upper
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized.replace(/\D/g, '')
|
return normalized.replace(/\D/g, '') || normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyDepositFieldValue(
|
export async function copyDepositFieldValue(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
kind: 'card' | 'sheba',
|
kind: 'card' | 'account' | 'sheba',
|
||||||
emptyMessage: string,
|
emptyMessage: string,
|
||||||
) {
|
) {
|
||||||
const raw = String(value ?? '').trim()
|
const raw = String(value ?? '').trim()
|
||||||
|
|||||||
+65
-10
@@ -8,28 +8,83 @@ export interface ProductFile {
|
|||||||
full_src?: string
|
full_src?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectBase(): string {
|
||||||
|
return resolveProjectBase().replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageRelativePath(value: string): string | null {
|
||||||
|
const match = value.match(/(?:^|\/)(storage\/.+)$/i)
|
||||||
|
return match ? match[1].replace(/^\/+/, '') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinProjectUrl(relative: string): string {
|
||||||
|
const path = relative.replace(/^\//, '')
|
||||||
|
let base = projectBase()
|
||||||
|
if (!base && typeof window !== 'undefined') {
|
||||||
|
base = `${window.location.origin}/server`
|
||||||
|
}
|
||||||
|
if (!base) {
|
||||||
|
return `/server/${path}`
|
||||||
|
}
|
||||||
|
base = base.replace(/\/$/, '')
|
||||||
|
if (path.startsWith('storage/') && !/\/(server|api)(\/|$)/i.test(base)) {
|
||||||
|
base = `${base}/server`
|
||||||
|
}
|
||||||
|
let url = `${base}/${path}`
|
||||||
|
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
|
||||||
|
url = url.replace(/^http:\/\//i, 'https://')
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL فایل روی API — مسیرهای `storage/` و آدرسهای localhost/http
|
||||||
|
* را به `node_server` میچسباند تا روی اندروید (PWA/TWA) لود شوند.
|
||||||
|
*/
|
||||||
|
export function normalizePublicFileUrl(raw?: string | null): string | null {
|
||||||
|
if (raw == null) return null
|
||||||
|
const trimmed = String(raw).trim()
|
||||||
|
if (!trimmed || trimmed.toLowerCase() === 'null' || trimmed.toLowerCase() === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (/^(data:|blob:)/i.test(trimmed)) return trimmed
|
||||||
|
|
||||||
|
const storagePath = storageRelativePath(trimmed)
|
||||||
|
if (storagePath) {
|
||||||
|
return joinProjectUrl(storagePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
|
||||||
|
return trimmed.replace(/^http:\/\//i, 'https://')
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinProjectUrl(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
export function getFileUrl(file?: ProductFile | null): string | null {
|
export function getFileUrl(file?: ProductFile | null): string | null {
|
||||||
if (!file) return null
|
if (!file) return null
|
||||||
|
|
||||||
for (const value of [file.full_src, file.url, file.src]) {
|
for (const value of [file.full_src, file.url, file.src]) {
|
||||||
if (!value) continue
|
if (!value) continue
|
||||||
if (/^https?:\/\//i.test(value)) return value
|
const normalized = normalizePublicFileUrl(value)
|
||||||
|
if (normalized) return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
const relative = file.src || file.full_src
|
return null
|
||||||
if (!relative) return null
|
|
||||||
|
|
||||||
const base = resolveProjectBase().replace(/\/$/, '')
|
|
||||||
if (!base) return relative.startsWith('/') ? relative : `/${relative}`
|
|
||||||
const path = String(relative).replace(/^\//, '')
|
|
||||||
return `${base}/${path}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve profile avatar URL from GET /customer/me payload */
|
/** Resolve profile avatar URL from GET /customer/me payload */
|
||||||
export function getUserAvatarUrl(user?: User | null): string | null {
|
export function getUserAvatarUrl(user?: User | null): string | null {
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
if (typeof user.avatar_url === 'string' && user.avatar_url) return user.avatar_url
|
if (typeof user.avatar_url === 'string' && user.avatar_url) {
|
||||||
if (typeof user.full_src === 'string' && user.full_src) return user.full_src
|
return normalizePublicFileUrl(user.avatar_url)
|
||||||
|
}
|
||||||
|
if (typeof user.full_src === 'string' && user.full_src) {
|
||||||
|
return normalizePublicFileUrl(user.full_src)
|
||||||
|
}
|
||||||
const nested = user.avatar || user.file
|
const nested = user.avatar || user.file
|
||||||
return getFileUrl(nested as ProductFile | undefined)
|
return getFileUrl(nested as ProductFile | undefined)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Lottieهای لوکال (`public/lottie`) — پیشدانلود تا مودال/لیست خالی نماند */
|
||||||
|
|
||||||
|
const LOTTIE_FILES = {
|
||||||
|
logoutConfirm: 'lottie/logoutConfirm.json',
|
||||||
|
emptyList: 'lottie/emptyList.json',
|
||||||
|
goldBars: 'lottie/goldBars.json',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type LottieAssetKey = keyof typeof LOTTIE_FILES
|
||||||
|
|
||||||
|
const cache = new Map<LottieAssetKey, string>()
|
||||||
|
|
||||||
|
export function lottieSrc(key: LottieAssetKey): string {
|
||||||
|
const base = import.meta.env.BASE_URL || '/'
|
||||||
|
const path = `${base}${LOTTIE_FILES[key]}`
|
||||||
|
return path.replace(/([^:]\/)\/+/g, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLottieData(key: LottieAssetKey): string | undefined {
|
||||||
|
return cache.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preloadLottieAssets(): Promise<void> {
|
||||||
|
const keys = Object.keys(LOTTIE_FILES) as LottieAssetKey[]
|
||||||
|
await Promise.all(
|
||||||
|
keys.map(async (key) => {
|
||||||
|
if (cache.has(key)) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(lottieSrc(key))
|
||||||
|
if (!res.ok) return
|
||||||
|
cache.set(key, await res.text())
|
||||||
|
} catch {
|
||||||
|
/* شبکه/آفلاین — بعداً از src لوکال میخواند */
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
const MOBILE_MEDIA = '(max-width: 767.98px)'
|
||||||
|
const KEYBOARD_GAP_PX = 16
|
||||||
|
const SCROLL_RETRY_MS = [0, 90, 220, 420, 640]
|
||||||
|
|
||||||
|
let started = false
|
||||||
|
let activeField: HTMLElement | null = null
|
||||||
|
let blurTimer = 0
|
||||||
|
|
||||||
|
function isMobileLayout(): boolean {
|
||||||
|
return window.matchMedia(MOBILE_MEDIA).matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKeyboardInset(): number {
|
||||||
|
const vv = window.visualViewport
|
||||||
|
if (!vv) return 0
|
||||||
|
return Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleViewportBottom(): number {
|
||||||
|
const vv = window.visualViewport
|
||||||
|
return vv ? vv.offsetTop + vv.height : window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncViewportVars(hasFocusedField: boolean) {
|
||||||
|
const vv = window.visualViewport
|
||||||
|
const inset = getKeyboardInset()
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty('--app-keyboard-inset', `${inset}px`)
|
||||||
|
|
||||||
|
if (vv) {
|
||||||
|
document.documentElement.style.setProperty('--app-visual-viewport-height', `${Math.round(vv.height)}px`)
|
||||||
|
document.documentElement.style.setProperty('--app-visual-viewport-offset-top', `${Math.round(vv.offsetTop)}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFocusedField && inset > 0) {
|
||||||
|
document.documentElement.dataset.keyboardOpen = 'true'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasFocusedField) {
|
||||||
|
delete document.documentElement.dataset.keyboardOpen
|
||||||
|
document.documentElement.style.setProperty('--app-keyboard-inset', '0px')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEditableTarget(node: EventTarget | null): HTMLElement | null {
|
||||||
|
if (!(node instanceof HTMLElement)) return null
|
||||||
|
|
||||||
|
const field = node.closest('input, textarea, select, [contenteditable="true"]')
|
||||||
|
if (!(field instanceof HTMLElement)) return null
|
||||||
|
|
||||||
|
if (field instanceof HTMLInputElement) {
|
||||||
|
if (field.disabled || field.readOnly || field.type === 'hidden') return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field instanceof HTMLTextAreaElement) {
|
||||||
|
if (field.disabled || field.readOnly) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field instanceof HTMLSelectElement && field.disabled) return null
|
||||||
|
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
function findScrollableAncestor(el: HTMLElement): HTMLElement | null {
|
||||||
|
let node: HTMLElement | null = el.parentElement
|
||||||
|
|
||||||
|
while (node && node !== document.body && node !== document.documentElement) {
|
||||||
|
const style = window.getComputedStyle(node)
|
||||||
|
const overflowY = style.overflowY
|
||||||
|
const scrollable = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
|
||||||
|
|
||||||
|
if (scrollable && node.scrollHeight > node.clientHeight + 1) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
node = node.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFieldVisible(field: HTMLElement) {
|
||||||
|
if (!isMobileLayout()) return
|
||||||
|
|
||||||
|
syncViewportVars(true)
|
||||||
|
|
||||||
|
const rect = field.getBoundingClientRect()
|
||||||
|
const visibleBottom = getVisibleViewportBottom() - KEYBOARD_GAP_PX
|
||||||
|
const scrollParent = findScrollableAncestor(field)
|
||||||
|
|
||||||
|
if (rect.bottom > visibleBottom) {
|
||||||
|
const delta = rect.bottom - visibleBottom
|
||||||
|
if (scrollParent) {
|
||||||
|
scrollParent.scrollTop += delta
|
||||||
|
} else {
|
||||||
|
window.scrollBy({ top: delta, left: 0, behavior: 'auto' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rect.top < KEYBOARD_GAP_PX && scrollParent) {
|
||||||
|
scrollParent.scrollTop += rect.top - KEYBOARD_GAP_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
field.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleEnsureVisible(field: HTMLElement) {
|
||||||
|
SCROLL_RETRY_MS.forEach((delay) => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const active = resolveEditableTarget(document.activeElement)
|
||||||
|
if (!active || active !== field) return
|
||||||
|
ensureFieldVisible(field)
|
||||||
|
}, delay)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocusIn(event: FocusEvent) {
|
||||||
|
if (!isMobileLayout()) return
|
||||||
|
|
||||||
|
const field = resolveEditableTarget(event.target)
|
||||||
|
if (!field) return
|
||||||
|
|
||||||
|
activeField = field
|
||||||
|
window.clearTimeout(blurTimer)
|
||||||
|
scheduleEnsureVisible(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocusOut() {
|
||||||
|
window.clearTimeout(blurTimer)
|
||||||
|
blurTimer = window.setTimeout(() => {
|
||||||
|
const next = resolveEditableTarget(document.activeElement)
|
||||||
|
activeField = next
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
syncViewportVars(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncViewportVars(true)
|
||||||
|
scheduleEnsureVisible(next)
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewportChange() {
|
||||||
|
if (!isMobileLayout()) return
|
||||||
|
|
||||||
|
syncViewportVars(Boolean(activeField))
|
||||||
|
|
||||||
|
const active = resolveEditableTarget(document.activeElement) ?? activeField
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
activeField = active
|
||||||
|
ensureFieldVisible(active)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** موبایل: با باز شدن کیبورد، فیلد فوکوسشده بالای کیبورد دیده شود. */
|
||||||
|
export function initMobileInputKeyboardScroll() {
|
||||||
|
if (typeof window === 'undefined' || started) return
|
||||||
|
started = true
|
||||||
|
|
||||||
|
document.addEventListener('focusin', onFocusIn, true)
|
||||||
|
document.addEventListener('focusout', onFocusOut, true)
|
||||||
|
window.visualViewport?.addEventListener('resize', onViewportChange, { passive: true })
|
||||||
|
window.visualViewport?.addEventListener('scroll', onViewportChange, { passive: true })
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.dispose(() => {
|
||||||
|
started = false
|
||||||
|
document.removeEventListener('focusin', onFocusIn, true)
|
||||||
|
document.removeEventListener('focusout', onFocusOut, true)
|
||||||
|
window.visualViewport?.removeEventListener('resize', onViewportChange)
|
||||||
|
window.visualViewport?.removeEventListener('scroll', onViewportChange)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,18 +13,68 @@ export function persianToEnglishDigits(value: string | number | null | undefined
|
|||||||
.replace(/٫/g, '.')
|
.replace(/٫/g, '.')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldNormalizeInputDigits(options: {
|
/**
|
||||||
|
* اعداد فارسی/عربی + جداکنندههای اعشار رایج ماشینحساب فارسی.
|
||||||
|
* - `slashAsDecimal` پیشفرض true: `/` → `.`
|
||||||
|
* - `commaAsThousand` true (مبلغ): `,` / `،` حذف میشوند
|
||||||
|
* - `commaAsThousand` false (وزن/اعشار): `,` / `،` هم → `.`
|
||||||
|
*/
|
||||||
|
export function normalizeDecimalInput(
|
||||||
|
val: string | number | null | undefined,
|
||||||
|
options?: { commaAsThousand?: boolean }
|
||||||
|
): string {
|
||||||
|
if (val == null || val === '') return ''
|
||||||
|
|
||||||
|
const commaAsThousand = options?.commaAsThousand ?? true
|
||||||
|
let normalized = persianToEnglishDigits(String(val))
|
||||||
|
|
||||||
|
if (commaAsThousand) {
|
||||||
|
normalized = normalized.replace(/[,،]/g, '')
|
||||||
|
} else {
|
||||||
|
normalized = normalized.replace(/[,،]/g, '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalized
|
||||||
|
.replace(/\//g, '.')
|
||||||
|
.replace(/[^\d.]/g, '')
|
||||||
|
|
||||||
|
const firstDot = normalized.indexOf('.')
|
||||||
|
if (firstDot !== -1) {
|
||||||
|
normalized =
|
||||||
|
normalized.slice(0, firstDot + 1) +
|
||||||
|
normalized.slice(firstDot + 1).replace(/\./g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDecimalInput(
|
||||||
|
val: string | number | null | undefined,
|
||||||
|
options?: { commaAsThousand?: boolean }
|
||||||
|
): number {
|
||||||
|
const normalized = normalizeDecimalInput(val, options)
|
||||||
|
if (!normalized) return NaN
|
||||||
|
return Number(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldNormalizeInputDigits(_options?: {
|
||||||
isPassword?: boolean
|
isPassword?: boolean
|
||||||
type?: string
|
type?: string
|
||||||
inputmode?: string
|
inputmode?: string
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (options.isPassword) {
|
// همیشه ارقام فارسی/عربی → انگلیسی (لاگین، گرم، تعداد، مظنه، …)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** فیلدهای وزن/مبلغ/عدد اعشاری که باید `/` را ممیز بگیرند */
|
||||||
|
export function shouldNormalizeDecimalSeparators(options: {
|
||||||
|
isPassword?: boolean
|
||||||
|
type?: string
|
||||||
|
inputmode?: string
|
||||||
|
}): boolean {
|
||||||
|
if (options.isPassword) return false
|
||||||
const type = String(options.type ?? '').trim().toLowerCase()
|
const type = String(options.type ?? '').trim().toLowerCase()
|
||||||
if (type === 'price' || type === 'number' || type === 'tel') {
|
if (type === 'price' || type === 'number') return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
const mode = String(options.inputmode ?? '').trim().toLowerCase()
|
const mode = String(options.inputmode ?? '').trim().toLowerCase()
|
||||||
return mode === 'numeric' || mode === 'decimal' || mode === 'number'
|
return mode === 'numeric' || mode === 'decimal' || mode === 'number'
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-3
@@ -7,6 +7,8 @@ import {
|
|||||||
priceWssEnabled,
|
priceWssEnabled,
|
||||||
shouldUsePricePolling,
|
shouldUsePricePolling,
|
||||||
} from '@/utils/priceSocketState'
|
} from '@/utils/priceSocketState'
|
||||||
|
import { applyBoardSpecialToLastPayload } from '@/utils/specialPricing'
|
||||||
|
import { useSettingsStore, type AppSetting } from '@/store/settingsStore'
|
||||||
|
|
||||||
const LAST_INTERVAL_MS = 10000
|
const LAST_INTERVAL_MS = 10000
|
||||||
|
|
||||||
@@ -16,10 +18,21 @@ let lastSubscribers = 0
|
|||||||
/** @deprecated از lastPrice استفاده کنید — برای سازگاری با کد قدیمی sync میشود */
|
/** @deprecated از lastPrice استفاده کنید — برای سازگاری با کد قدیمی sync میشود */
|
||||||
export const price = ref<Price>()
|
export const price = ref<Price>()
|
||||||
export const lastPrice = ref<Price>()
|
export const lastPrice = ref<Price>()
|
||||||
|
/** حداقل یک بار تلاش برای گرفتن مظنه تمام شده (موفق یا ناموفق) */
|
||||||
|
export const lastPriceReady = ref(false)
|
||||||
|
/**
|
||||||
|
* بعد از اولین پاسخ خالی/no_quote، چند لحظه صبر میکنیم تا تیک SSE/زنده برسد
|
||||||
|
* و «مظنه در دسترس نیست» زودهنگام نشان داده نشود.
|
||||||
|
*/
|
||||||
|
export const lastPriceSettleGeneration = ref(0)
|
||||||
export const olderPrice = ref<number | undefined>(0)
|
export const olderPrice = ref<number | undefined>(0)
|
||||||
|
|
||||||
|
const QUOTE_ABSENCE_SETTLE_MS = 2500
|
||||||
|
|
||||||
let lastPriceSourceAt = 0
|
let lastPriceSourceAt = 0
|
||||||
let consecutivePriceFetchFailures = 0
|
let consecutivePriceFetchFailures = 0
|
||||||
|
let lastPriceReadyAt = 0
|
||||||
|
let quoteSettleTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
function hasValidPricePayload(data?: Price | null): boolean {
|
function hasValidPricePayload(data?: Price | null): boolean {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -33,13 +46,77 @@ function hasValidPricePayload(data?: Price | null): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTradingHoursSettingsPatch(data: unknown) {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = (data as { settings_patch?: unknown }).settings_patch
|
||||||
|
if (!Array.isArray(patch) || patch.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useSettingsStore().mergeSettings(patch as AppSetting[])
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQuoteSettleTimer() {
|
||||||
|
if (quoteSettleTimer) {
|
||||||
|
clearTimeout(quoteSettleTimer)
|
||||||
|
quoteSettleTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpQuoteSettle() {
|
||||||
|
lastPriceSettleGeneration.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleQuoteAbsenceSettle() {
|
||||||
|
clearQuoteSettleTimer()
|
||||||
|
if (hasValidPricePayload(lastPrice.value) || lastPrice.value?.status === 'closed') {
|
||||||
|
bumpQuoteSettle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const elapsed = lastPriceReadyAt > 0 ? Date.now() - lastPriceReadyAt : 0
|
||||||
|
const wait = Math.max(0, QUOTE_ABSENCE_SETTLE_MS - elapsed)
|
||||||
|
quoteSettleTimer = setTimeout(() => {
|
||||||
|
quoteSettleTimer = undefined
|
||||||
|
bumpQuoteSettle()
|
||||||
|
}, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markLastPriceReady() {
|
||||||
|
if (!lastPriceReady.value) {
|
||||||
|
lastPriceReadyAt = Date.now()
|
||||||
|
}
|
||||||
|
lastPriceReady.value = true
|
||||||
|
scheduleQuoteAbsenceSettle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** قیمت آمده، بسته قطعی است، یا پنجرهٔ انتظارِ مظنه خالی تمام شده */
|
||||||
|
export function isLastPriceAbsenceSettled(): boolean {
|
||||||
|
void lastPriceSettleGeneration.value
|
||||||
|
if (!lastPriceReady.value) return false
|
||||||
|
if (hasValidPricePayload(lastPrice.value)) return true
|
||||||
|
if (lastPrice.value?.status === 'closed') return true
|
||||||
|
if (lastPriceReadyAt <= 0) return false
|
||||||
|
return Date.now() - lastPriceReadyAt >= QUOTE_ABSENCE_SETTLE_MS
|
||||||
|
}
|
||||||
|
|
||||||
export function markPriceSourceReceived() {
|
export function markPriceSourceReceived() {
|
||||||
lastPriceSourceAt = Date.now()
|
lastPriceSourceAt = Date.now()
|
||||||
consecutivePriceFetchFailures = 0
|
consecutivePriceFetchFailures = 0
|
||||||
|
void import('@/utils/priceSocket').then(({ clearPriceNetworkAlert }) => {
|
||||||
|
clearPriceNetworkAlert()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markPriceSourceFetchFailed() {
|
export function markPriceSourceFetchFailed() {
|
||||||
consecutivePriceFetchFailures += 1
|
consecutivePriceFetchFailures += 1
|
||||||
|
if (consecutivePriceFetchFailures >= 2) {
|
||||||
|
void import('@/utils/priceSocket').then(({ notifyPriceLastFetchFailed }) => {
|
||||||
|
notifyPriceLastFetchFailed()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean {
|
export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean {
|
||||||
@@ -163,18 +240,38 @@ export async function getLastPrices(): Promise<boolean> {
|
|||||||
console.log(e)
|
console.log(e)
|
||||||
markPriceSourceFetchFailed()
|
markPriceSourceFetchFailed()
|
||||||
return false
|
return false
|
||||||
|
} finally {
|
||||||
|
markLastPriceReady()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyLastPricePayload(dataLast: Price) {
|
export function applyLastPricePayload(dataLast: Price) {
|
||||||
const normalized = normalizePriceResponse(dataLast)
|
applyTradingHoursSettingsPatch(dataLast)
|
||||||
if (lastPrice.value?.price_last != normalized.price_last) {
|
const normalized = normalizePriceResponse(
|
||||||
|
applyBoardSpecialToLastPayload(dataLast as Record<string, unknown>) as Price,
|
||||||
|
)
|
||||||
|
const nextBuy = getTradeBuyPrice(normalized)
|
||||||
|
const prevBuy = getTradeBuyPrice(lastPrice.value)
|
||||||
|
const nextValid = hasValidPricePayload(normalized)
|
||||||
|
const prevValid = hasValidPricePayload(lastPrice.value)
|
||||||
|
|
||||||
|
// قطع فید / no_quote خالی — قیمت فعلی را پاک نکن؛ فقط closed قطعی اجازهٔ صفر شدن دارد
|
||||||
|
if (!nextValid && prevValid && normalized.status !== 'closed') {
|
||||||
|
markLastPriceReady()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevBuy > 0 && nextBuy > 0 && prevBuy !== nextBuy) {
|
||||||
|
olderPrice.value = prevBuy
|
||||||
|
} else if (lastPrice.value?.price_last != normalized.price_last) {
|
||||||
olderPrice.value = lastPrice.value?.price_last
|
olderPrice.value = lastPrice.value?.price_last
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPrice.value = normalized
|
lastPrice.value = normalized
|
||||||
|
markLastPriceReady()
|
||||||
price.value = {
|
price.value = {
|
||||||
...normalized,
|
...normalized,
|
||||||
buy: getTradeBuyPrice(normalized),
|
buy: nextBuy,
|
||||||
sell: getTradeSellPrice(normalized),
|
sell: getTradeSellPrice(normalized),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+190
-40
@@ -9,6 +9,10 @@ import {
|
|||||||
priceStreamLifecycle,
|
priceStreamLifecycle,
|
||||||
priceWssEnabled,
|
priceWssEnabled,
|
||||||
} from '@/utils/priceSocketState'
|
} from '@/utils/priceSocketState'
|
||||||
|
import {
|
||||||
|
applyBoardSpecialToLastPayload,
|
||||||
|
setSpecialPricingPayload,
|
||||||
|
} from '@/utils/specialPricing'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
priceSocketConnected,
|
priceSocketConnected,
|
||||||
@@ -20,7 +24,8 @@ export {
|
|||||||
|
|
||||||
const RECONNECT_BASE_MS = 3000
|
const RECONNECT_BASE_MS = 3000
|
||||||
const RECONNECT_MAX_MS = 30000
|
const RECONNECT_MAX_MS = 30000
|
||||||
const FAILURES_BEFORE_ALERT = 4
|
/** چند بار خطای پشتسرهم /api/price/last قبل از نمایش مدال */
|
||||||
|
const LAST_FETCH_FAILURES_BEFORE_ALERT = 2
|
||||||
const ALERT_COOLDOWN_MS = 5 * 60 * 1000
|
const ALERT_COOLDOWN_MS = 5 * 60 * 1000
|
||||||
const FALLBACK_POLL_INTERVAL_MS = 10000
|
const FALLBACK_POLL_INTERVAL_MS = 10000
|
||||||
/** بدون قیمت معتبر از مرجع (SSE یا API) */
|
/** بدون قیمت معتبر از مرجع (SSE یا API) */
|
||||||
@@ -29,8 +34,95 @@ const PRICE_SOURCE_STALE_MS_DESKTOP = 35000
|
|||||||
/** بدون event قیمت؛ ping هر ۵ث از سرور activity را تازه میکند */
|
/** بدون event قیمت؛ ping هر ۵ث از سرور activity را تازه میکند */
|
||||||
const STALE_STREAM_MS = 120000
|
const STALE_STREAM_MS = 120000
|
||||||
const FALLBACK_POLL_DELAY_MS = 2500
|
const FALLBACK_POLL_DELAY_MS = 2500
|
||||||
|
const STREAM_CONNECTING = 0
|
||||||
|
const STREAM_OPEN = 1
|
||||||
|
const STREAM_CLOSED = 2
|
||||||
|
|
||||||
let eventSource: EventSource | null = null
|
/** EventSource نمیتواند Authorization بفرستد؛ استریم همچنان auth:customer میخواهد. */
|
||||||
|
class AuthPriceStream extends EventTarget {
|
||||||
|
readyState = STREAM_CONNECTING
|
||||||
|
onopen: (() => void) | null = null
|
||||||
|
onerror: (() => void) | null = null
|
||||||
|
onmessage: (() => void) | null = null
|
||||||
|
private abort = new AbortController()
|
||||||
|
|
||||||
|
constructor(url: string, token: string) {
|
||||||
|
super()
|
||||||
|
void this.start(url, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.readyState = STREAM_CLOSED
|
||||||
|
this.abort.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async start(url: string, token: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: this.abort.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
this.readyState = STREAM_CLOSED
|
||||||
|
this.onerror?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readyState = STREAM_OPEN
|
||||||
|
this.onopen?.()
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
buffer = this.flushSse(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readyState = STREAM_CLOSED
|
||||||
|
this.onerror?.()
|
||||||
|
} catch {
|
||||||
|
if (this.abort.signal.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.readyState = STREAM_CLOSED
|
||||||
|
this.onerror?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushSse(buffer: string): string {
|
||||||
|
const blocks = buffer.split('\n\n')
|
||||||
|
const rest = blocks.pop() ?? ''
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
let event = 'message'
|
||||||
|
const data: string[] = []
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
event = line.slice(6).trim()
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
data.push(line.slice(5).trimStart())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new MessageEvent(event, { data: data.join('\n') }))
|
||||||
|
this.onmessage?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventSource: AuthPriceStream | null = null
|
||||||
let subscribers = 0
|
let subscribers = 0
|
||||||
let stopped = false
|
let stopped = false
|
||||||
let getSetting: ((key: string) => unknown) | null = null
|
let getSetting: ((key: string) => unknown) | null = null
|
||||||
@@ -99,16 +191,12 @@ function resolveStreamUrl(): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = findStoredAuthToken()
|
if (!findStoredAuthToken()) {
|
||||||
if (!token) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '')
|
const base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '')
|
||||||
const url = new URL(`${base}${resolveStreamPath()}`)
|
return `${base}${resolveStreamPath()}`
|
||||||
url.searchParams.set('token', token)
|
|
||||||
|
|
||||||
return url.toString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearReconnectTimer() {
|
function clearReconnectTimer() {
|
||||||
@@ -137,7 +225,7 @@ function startStaleCheck() {
|
|||||||
stopped
|
stopped
|
||||||
|| subscribers <= 0
|
|| subscribers <= 0
|
||||||
|| !eventSource
|
|| !eventSource
|
||||||
|| eventSource.readyState !== EventSource.OPEN
|
|| eventSource.readyState !== STREAM_OPEN
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -190,6 +278,16 @@ function scheduleFallbackPoll() {
|
|||||||
}, FALLBACK_POLL_DELAY_MS)
|
}, FALLBACK_POLL_DELAY_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearPriceNetworkAlert() {
|
||||||
|
networkAlertOpen = false
|
||||||
|
pendingPriceAlert = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** وقتی /api/price/last خطا میدهد (از polling عادی یا fallback) */
|
||||||
|
export function notifyPriceLastFetchFailed() {
|
||||||
|
void maybeShowNetworkAlert()
|
||||||
|
}
|
||||||
|
|
||||||
async function runFallbackPoll() {
|
async function runFallbackPoll() {
|
||||||
if (!priceWssEnabled.value || priceSocketConnected.value) {
|
if (!priceWssEnabled.value || priceSocketConnected.value) {
|
||||||
return
|
return
|
||||||
@@ -213,8 +311,9 @@ async function runFallbackPoll() {
|
|||||||
&& priceResult.value === true
|
&& priceResult.value === true
|
||||||
&& hasRecentPriceFromSource(getPriceSourceStaleMs())
|
&& hasRecentPriceFromSource(getPriceSourceStaleMs())
|
||||||
|
|
||||||
if (!priceOk) {
|
if (priceOk) {
|
||||||
markPriceSourceFetchFailed()
|
clearPriceNetworkAlert()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void maybeShowNetworkAlert()
|
void maybeShowNetworkAlert()
|
||||||
@@ -243,25 +342,7 @@ async function isRealPriceOutage(): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchFailures = consecutivePriceSourceFailures()
|
return consecutivePriceSourceFailures() >= LAST_FETCH_FAILURES_BEFORE_ALERT
|
||||||
|
|
||||||
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() {
|
function showNetworkAlert() {
|
||||||
@@ -322,7 +403,6 @@ function scheduleReconnect() {
|
|||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectAttempt += 1
|
reconnectAttempt += 1
|
||||||
connect()
|
connect()
|
||||||
maybeShowNetworkAlert()
|
|
||||||
}, delay)
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,13 +411,61 @@ async function applyLastFromStream(last: Price) {
|
|||||||
applyLastPricePayload(last)
|
applyLastPricePayload(last)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applySettingsFromStream(payload: unknown) {
|
||||||
|
const items = Array.isArray(payload)
|
||||||
|
? payload
|
||||||
|
: payload && typeof payload === 'object' && Array.isArray((payload as { items?: unknown }).items)
|
||||||
|
? (payload as { items: unknown[] }).items
|
||||||
|
: null
|
||||||
|
if (!items?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { useSettingsStore } = await import('@/store/settingsStore')
|
||||||
|
const { touchStoreHoursSettings } = await import('@/utils/storeHours')
|
||||||
|
useSettingsStore().mergeSettings(items as Array<{ key?: string; value?: unknown }>)
|
||||||
|
touchStoreHoursSettings()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Settings SSE apply failed', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleLastPayload(payload: unknown) {
|
function handleLastPayload(payload: unknown) {
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (!payload || typeof payload !== 'object') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const last = payload as Price
|
const raw = payload as Price & { settings_patch?: unknown }
|
||||||
if (last.price_last == null && last.price == null && last.buy == null) {
|
const last = applyBoardSpecialToLastPayload(raw)
|
||||||
|
const hasDisplayable =
|
||||||
|
Number(last.buy ?? 0) > 0
|
||||||
|
|| Number(last.sell ?? 0) > 0
|
||||||
|
|| Number(last.price_last ?? 0) > 0
|
||||||
|
|| Number(last.price ?? 0) > 0
|
||||||
|
const hasStatus = last.status != null || typeof last.closed === 'boolean'
|
||||||
|
|
||||||
|
if (!hasDisplayable && !hasStatus) {
|
||||||
|
if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) {
|
||||||
|
touchStreamActivity()
|
||||||
|
void applySettingsFromStream(raw.settings_patch)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE گاهی last خالی/no_quote میفرستد؛ قیمت معتبر فعلی را پاک نکن
|
||||||
|
if (!hasDisplayable) {
|
||||||
|
if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) {
|
||||||
|
touchStreamActivity()
|
||||||
|
void applySettingsFromStream(raw.settings_patch)
|
||||||
|
}
|
||||||
|
void import('@/utils/price').then(({ getTradeBuyPrice, lastPrice }) => {
|
||||||
|
const keepCurrent =
|
||||||
|
getTradeBuyPrice(lastPrice.value) > 0 && last.status !== 'closed'
|
||||||
|
touchStreamActivity()
|
||||||
|
if (keepCurrent) return
|
||||||
|
void applyLastFromStream(last)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +473,11 @@ function handleLastPayload(payload: unknown) {
|
|||||||
void applyLastFromStream(last)
|
void applyLastFromStream(last)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSettingsPayload(payload: unknown) {
|
||||||
|
touchStreamActivity()
|
||||||
|
void applySettingsFromStream(payload)
|
||||||
|
}
|
||||||
|
|
||||||
async function applyListFromStream(products: Product[]) {
|
async function applyListFromStream(products: Product[]) {
|
||||||
const { applyProductsListFromSocket } = await import(
|
const { applyProductsListFromSocket } = await import(
|
||||||
'@/views/base/products/composables/useProductsPageData'
|
'@/views/base/products/composables/useProductsPageData'
|
||||||
@@ -411,7 +544,7 @@ async function applyUnreadCountFromStreamPayload(payload: Record<string, unknown
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindStreamEvents(source: EventSource) {
|
function bindStreamEvents(source: AuthPriceStream) {
|
||||||
source.addEventListener('last', (event) => {
|
source.addEventListener('last', (event) => {
|
||||||
try {
|
try {
|
||||||
handleLastPayload(JSON.parse(event.data))
|
handleLastPayload(JSON.parse(event.data))
|
||||||
@@ -420,6 +553,14 @@ function bindStreamEvents(source: EventSource) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
source.addEventListener('settings', (event) => {
|
||||||
|
try {
|
||||||
|
handleSettingsPayload(JSON.parse(event.data))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Price SSE settings parse failed', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
source.addEventListener('list', (event) => {
|
source.addEventListener('list', (event) => {
|
||||||
try {
|
try {
|
||||||
handleListPayload(JSON.parse(event.data))
|
handleListPayload(JSON.parse(event.data))
|
||||||
@@ -452,6 +593,14 @@ function bindStreamEvents(source: EventSource) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
source.addEventListener('special-pricing', (event) => {
|
||||||
|
try {
|
||||||
|
setSpecialPricingPayload(JSON.parse(event.data))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Special-pricing SSE parse failed', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
source.addEventListener('ping', () => {
|
source.addEventListener('ping', () => {
|
||||||
touchStreamActivity()
|
touchStreamActivity()
|
||||||
})
|
})
|
||||||
@@ -493,7 +642,8 @@ function connect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = resolveStreamUrl()
|
const url = resolveStreamUrl()
|
||||||
if (!url) {
|
const token = findStoredAuthToken()
|
||||||
|
if (!url || !token) {
|
||||||
priceSocketConnected.value = false
|
priceSocketConnected.value = false
|
||||||
priceStreamLifecycle.value = 'closed'
|
priceStreamLifecycle.value = 'closed'
|
||||||
clearConnectingLifecycle()
|
clearConnectingLifecycle()
|
||||||
@@ -502,7 +652,7 @@ function connect() {
|
|||||||
|
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
const state = eventSource.readyState
|
const state = eventSource.readyState
|
||||||
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
|
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
disconnectSourceOnly()
|
disconnectSourceOnly()
|
||||||
@@ -511,7 +661,7 @@ function connect() {
|
|||||||
markConnectingLifecycle()
|
markConnectingLifecycle()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
eventSource = new EventSource(url)
|
eventSource = new AuthPriceStream(url, token)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Price SSE connect failed', error)
|
console.warn('Price SSE connect failed', error)
|
||||||
handleStreamLost()
|
handleStreamLost()
|
||||||
@@ -534,7 +684,7 @@ function connect() {
|
|||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
const state = eventSource?.readyState
|
const state = eventSource?.readyState
|
||||||
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
|
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,7 +798,7 @@ function onVisibilityChange() {
|
|||||||
void maybeShowNetworkAlert()
|
void maybeShowNetworkAlert()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
|
if (!eventSource || eventSource.readyState !== STREAM_OPEN) {
|
||||||
forceReconnect()
|
forceReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -658,7 +808,7 @@ function onPageShow(event: PageTransitionEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) {
|
if (event.persisted || !eventSource || eventSource.readyState !== STREAM_OPEN) {
|
||||||
reconnectAttempt = 0
|
reconnectAttempt = 0
|
||||||
networkAlertOpen = false
|
networkAlertOpen = false
|
||||||
forceReconnect()
|
forceReconnect()
|
||||||
|
|||||||
@@ -648,19 +648,31 @@ export function enrichInventoryWithCatalog<T extends { id?: number }>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withProductImageCacheBust(
|
||||||
|
url: string,
|
||||||
|
product?: { file_id?: number | string | null; file?: ProductFile | null } | null,
|
||||||
|
): string {
|
||||||
|
if (!url || !/^https?:\/\//i.test(url)) return url
|
||||||
|
const version = product?.file_id ?? product?.file?.id
|
||||||
|
if (version == null || version === '' || Number(version) <= 0) return url
|
||||||
|
if (/[?&]v=/.test(url)) return url
|
||||||
|
return `${url}${url.includes('?') ? '&' : '?'}v=${encodeURIComponent(String(version))}`
|
||||||
|
}
|
||||||
|
|
||||||
export function getProductDisplayImage(product?: {
|
export function getProductDisplayImage(product?: {
|
||||||
group_type?: string | null
|
group_type?: string | null
|
||||||
title?: string | null
|
title?: string | null
|
||||||
file?: ProductFile | null
|
file?: ProductFile | null
|
||||||
|
file_id?: number | string | null
|
||||||
img?: string | null
|
img?: string | null
|
||||||
} | null): string {
|
} | null): string {
|
||||||
const fileUrl = getFileUrl(product?.file)
|
const fileUrl = getFileUrl(product?.file)
|
||||||
if (fileUrl) {
|
if (fileUrl) {
|
||||||
return fileUrl
|
return withProductImageCacheBust(fileUrl, product)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (product?.img && /^https?:\/\//i.test(product.img)) {
|
if (product?.img && /^https?:\/\//i.test(product.img)) {
|
||||||
return product.img
|
return withProductImageCacheBust(product.img, product)
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = normalizeProductGroupType(product?.group_type)
|
const group = normalizeProductGroupType(product?.group_type)
|
||||||
|
|||||||
@@ -43,19 +43,23 @@ export function resolveProductCashFeeRial(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProductCashFeeBuyRial(product: ProductCashFeeSource, basePriceRial: number) {
|
export function resolveProductCashFeeBuyRial(product: ProductCashFeeSource, basePriceRial: number) {
|
||||||
return resolveProductCashFeeRial(
|
const baseFee = resolveProductCashFeeRial(
|
||||||
product.cash_fee_buy,
|
product.cash_fee_buy,
|
||||||
product.cash_fee_buy_is_percent,
|
product.cash_fee_buy_is_percent,
|
||||||
basePriceRial,
|
basePriceRial,
|
||||||
)
|
)
|
||||||
|
const special = Number((product as { special_profit_buy?: number }).special_profit_buy ?? 0)
|
||||||
|
return baseFee + (Number.isFinite(special) ? Math.round(special) : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProductCashFeeSellRial(product: ProductCashFeeSource, basePriceRial: number) {
|
export function resolveProductCashFeeSellRial(product: ProductCashFeeSource, basePriceRial: number) {
|
||||||
return resolveProductCashFeeRial(
|
const baseFee = resolveProductCashFeeRial(
|
||||||
product.cash_fee_sell,
|
product.cash_fee_sell,
|
||||||
product.cash_fee_sell_is_percent,
|
product.cash_fee_sell_is_percent,
|
||||||
basePriceRial,
|
basePriceRial,
|
||||||
)
|
)
|
||||||
|
const special = Number((product as { special_profit_sell?: number }).special_profit_sell ?? 0)
|
||||||
|
return baseFee + (Number.isFinite(special) ? Math.round(special) : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatProductCashFeeLabel(
|
export function formatProductCashFeeLabel(
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface ProductUnitSource {
|
|||||||
group_type?: string | null
|
group_type?: string | null
|
||||||
amount?: number | null
|
amount?: number | null
|
||||||
trade_by_quantity?: boolean | number | null
|
trade_by_quantity?: boolean | number | null
|
||||||
|
price_formula_enabled?: boolean | number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWeightBasedProductUnit(unit?: string | null) {
|
export function isWeightBasedProductUnit(unit?: string | null) {
|
||||||
@@ -80,6 +81,11 @@ export function hasProductUnitWeight(product?: ProductUnitSource | null) {
|
|||||||
|
|
||||||
/** ضریب قیمت — فقط «متغیر» بدون مرجع اتوماتیک: قیمت هر گرم × وزن */
|
/** ضریب قیمت — فقط «متغیر» بدون مرجع اتوماتیک: قیمت هر گرم × وزن */
|
||||||
export function productPriceAmountFactor(product?: ProductUnitSource | null) {
|
export function productPriceAmountFactor(product?: ProductUnitSource | null) {
|
||||||
|
// قیمت فرمولی = قیمت واحد هر عدد (وزن داخل فرمول است)
|
||||||
|
if (product?.price_formula_enabled) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if (product?.reading_method) {
|
if (product?.reading_method) {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,22 @@
|
|||||||
|
/** سقف inset بالا (Dynamic Island / notch) — فقط وقتی env صفر است و واقعاً iOS standalone */
|
||||||
|
const MAX_SAFE_TOP_PX = 59
|
||||||
|
/** فقط PWA نصبشدهٔ آیفون — سافاری این را نگیرد */
|
||||||
|
const IOS_INSTALLED_TOP_BUFFER_PX = 10
|
||||||
|
|
||||||
|
function isStandaloneDisplay() {
|
||||||
|
return (
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** iPhone / iPad — شامل iPadOS که خود را Mac معرفی میکند */
|
||||||
|
function isAppleTouchDevice() {
|
||||||
|
const ua = window.navigator.userAgent || ''
|
||||||
|
if (/iP(hone|od|ad)/i.test(ua)) return true
|
||||||
|
return window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* روی بعضی buildهای iOS PWA، env(safe-area-inset-*) داخل CSS var صفر میماند
|
* روی بعضی buildهای iOS PWA، env(safe-area-inset-*) داخل CSS var صفر میماند
|
||||||
* در حالی که محتوا واقعاً زیر نوار وضعیت رسم میشود. این تابع مقدار واقعی را
|
* در حالی که محتوا واقعاً زیر نوار وضعیت رسم میشود. این تابع مقدار واقعی را
|
||||||
@@ -36,16 +55,10 @@ export function syncSafeAreaInsets() {
|
|||||||
root.style.setProperty('--app-safe-right', right)
|
root.style.setProperty('--app-safe-right', right)
|
||||||
root.style.setProperty('--app-safe-left', left)
|
root.style.setProperty('--app-safe-left', left)
|
||||||
|
|
||||||
// اگر env صفر بود ولی PWA standalone هستیم، حداقل فضای نوار وضعیت را جبران کن
|
|
||||||
const topPx = Number.parseFloat(top) || 0
|
const topPx = Number.parseFloat(top) || 0
|
||||||
const isStandalone =
|
if (isStandaloneDisplay() && isAppleTouchDevice()) {
|
||||||
window.matchMedia('(display-mode: standalone)').matches ||
|
const next = (topPx < 1 ? MAX_SAFE_TOP_PX : topPx) + IOS_INSTALLED_TOP_BUFFER_PX
|
||||||
// iOS Safari legacy
|
root.style.setProperty('--app-safe-top', `${next}px`)
|
||||||
Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone)
|
|
||||||
|
|
||||||
if (isStandalone && topPx < 1) {
|
|
||||||
// Dynamic Island ≈ ۵۹px؛ بدون env حداقل این را جبران میکنیم
|
|
||||||
root.style.setProperty('--app-safe-top', '59px')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
const OVERLAY_ID = 'sa-server-unavailable-overlay'
|
||||||
|
export const SERVER_UNAVAILABLE_MESSAGE = 'خطا در برقراری ارتباط با سرور'
|
||||||
|
const MESSAGE = SERVER_UNAVAILABLE_MESSAGE
|
||||||
|
|
||||||
|
/** Thrown after kill-switch overlay is shown — bootstrap must not replace it with internet error */
|
||||||
|
export class ServerUnavailableError extends Error {
|
||||||
|
readonly isServerUnavailable = true
|
||||||
|
|
||||||
|
constructor(message = MESSAGE) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ServerUnavailableError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isServerUnavailableError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') return false
|
||||||
|
if (error instanceof ServerUnavailableError) return true
|
||||||
|
const record = error as { isServerUnavailable?: unknown; message?: unknown }
|
||||||
|
return (
|
||||||
|
record.isServerUnavailable === true ||
|
||||||
|
record.message === SERVER_UNAVAILABLE_MESSAGE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isServerUnreachableConfig(config: unknown): boolean {
|
||||||
|
if (!config || typeof config !== 'object') return false
|
||||||
|
const root = config as Record<string, unknown>
|
||||||
|
const user =
|
||||||
|
root.user && typeof root.user === 'object'
|
||||||
|
? (root.user as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (root.server_reachable === false) return true
|
||||||
|
if (user && user.server_reachable === false) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showServerUnavailableOverlay(message = MESSAGE) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
let root = document.getElementById(OVERLAY_ID)
|
||||||
|
if (!root) {
|
||||||
|
root = document.createElement('div')
|
||||||
|
root.id = OVERLAY_ID
|
||||||
|
document.body.appendChild(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// z-index بالاتر از boot-error تا پیام قطع عمدی با «خطای اینترنت» عوض نشود
|
||||||
|
root.innerHTML = `
|
||||||
|
<div style="position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;direction:rtl;font-family:sans-serif;padding:24px;background:#ffffff;color:#111827;">
|
||||||
|
<div style="width:min(420px,100%);text-align:center;background:#fff;border:1px solid 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 20px;font-size:20px;">${message}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="location.reload()"
|
||||||
|
style="padding:11px 22px;border:0;border-radius:12px;background:#2563eb;color:white;cursor:pointer;font-weight:700;"
|
||||||
|
>
|
||||||
|
تلاش مجدد
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideServerUnavailableOverlay() {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
const root = document.getElementById(OVERLAY_ID)
|
||||||
|
if (root) root.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertServerReachableConfig(config: unknown) {
|
||||||
|
if (isServerUnreachableConfig(config)) {
|
||||||
|
showServerUnavailableOverlay()
|
||||||
|
throw new ServerUnavailableError()
|
||||||
|
}
|
||||||
|
}
|
||||||
+189
-5
@@ -1,7 +1,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getSettings } from '@/api/configuration'
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
|
|
||||||
export const serviceFeePercentage = ref<number>(0.01)
|
export const serviceFeePercentage = ref<number>(0)
|
||||||
|
|
||||||
/** true = مبالغ رند: فروش → ریال دقیق (کارمزد از طلا)؛ خرید → وزن دقیق (کارمزد از ریال) */
|
/** true = مبالغ رند: فروش → ریال دقیق (کارمزد از طلا)؛ خرید → وزن دقیق (کارمزد از ریال) */
|
||||||
export const feeDeductFromGold = ref(false)
|
export const feeDeductFromGold = ref(false)
|
||||||
@@ -9,6 +9,11 @@ export const feeDeductFromGold = ref(false)
|
|||||||
/** آخرین فیلدی که کاربر مستقیم ویرایش کرده — مبنای بهروزرسانی هنگام تغییر قیمت */
|
/** آخرین فیلدی که کاربر مستقیم ویرایش کرده — مبنای بهروزرسانی هنگام تغییر قیمت */
|
||||||
export type TradeInputLock = 'gram' | 'rial' | null
|
export type TradeInputLock = 'gram' | 'rial' | null
|
||||||
|
|
||||||
|
/** ریال قفل است؟ (کاربر مبلغ ریالی را مستقیم وارد کرده) → کارمزد از طلا */
|
||||||
|
export function isRialLocked(inputLock?: TradeInputLock): boolean {
|
||||||
|
return inputLock === 'rial'
|
||||||
|
}
|
||||||
|
|
||||||
/** حداکثر اعشار ذخیره/ارسال به سرور (مطابق orders.amount) */
|
/** حداکثر اعشار ذخیره/ارسال به سرور (مطابق orders.amount) */
|
||||||
export const TRADE_GRAM_STORAGE_DECIMALS = 10
|
export const TRADE_GRAM_STORAGE_DECIMALS = 10
|
||||||
|
|
||||||
@@ -44,13 +49,18 @@ export function normalizeServiceFeeRatio(value: number): number {
|
|||||||
|
|
||||||
export async function loadServiceFeeFromSettings() {
|
export async function loadServiceFeeFromSettings() {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings()
|
const settingsStore = useSettingsStore()
|
||||||
if (settings && Array.isArray(settings)) {
|
if (!settingsStore.loaded) {
|
||||||
|
await settingsStore.fetchSettings()
|
||||||
|
}
|
||||||
|
const settings = settingsStore.settings
|
||||||
|
if (settings?.length) {
|
||||||
const feeSetting = settings.find(
|
const feeSetting = settings.find(
|
||||||
(s) => s?.key === 'fee_service_charge_percentage' && s?.is_active === true
|
(s) => s?.key === 'fee_service_charge_percentage' && s?.is_active === true
|
||||||
)
|
)
|
||||||
if (feeSetting?.value !== undefined && feeSetting?.value !== null) {
|
if (feeSetting?.value !== undefined && feeSetting?.value !== null) {
|
||||||
serviceFeePercentage.value = normalizeServiceFeeRatio(Number(feeSetting.value))
|
const raw = String(feeSetting.value).trim()
|
||||||
|
serviceFeePercentage.value = raw === '' ? 0 : normalizeServiceFeeRatio(Number(raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
const feeModeSetting = settings.find(
|
const feeModeSetting = settings.find(
|
||||||
@@ -215,3 +225,177 @@ export function calcSellGoldDebit(goldValue?: number | null, feeInGrams?: number
|
|||||||
}
|
}
|
||||||
return gold
|
return gold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** مبلغ پایه معامله قبل از اعمال کارمزد ریالی (خرید با افزودن کارمزد به ریال) */
|
||||||
|
export function calcTradeBasePriceRial(orderGoldAmount: number, unitPriceRial: number) {
|
||||||
|
return Math.round(Number(orderGoldAmount) * Number(unitPriceRial))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeConfirmRow {
|
||||||
|
label: string
|
||||||
|
gram?: number | null
|
||||||
|
rial?: number | null
|
||||||
|
bold?: boolean
|
||||||
|
minus?: boolean
|
||||||
|
plus?: boolean
|
||||||
|
dividerBefore?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeConfirmSection {
|
||||||
|
heading: string
|
||||||
|
rows: TradeConfirmRow[]
|
||||||
|
footnote?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeConfirmBreakdown {
|
||||||
|
sections: TradeConfirmSection[]
|
||||||
|
formHint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTradeFee(feeInGrams: number, feeInRial: number) {
|
||||||
|
return feeInGrams > 0 || feeInRial > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function grossGoldFromRial(rial: number, unitPriceRial: number) {
|
||||||
|
if (!rial || !unitPriceRial) return 0
|
||||||
|
return normalizeOrderGoldAmount(rial / unitPriceRial)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جزئیات مدال تأیید — دو بخش «کسر از کیف» و «واریز به کیف».
|
||||||
|
* فقط بر پایهٔ قفل ورودی تصمیم میگیرد:
|
||||||
|
* • ریال قفل → مبلغ دقیق، کارمزد از طلا.
|
||||||
|
* • گرم قفل → وزن دقیق، کارمزد از ریال.
|
||||||
|
*/
|
||||||
|
export function buildTradeConfirmBreakdown(params: {
|
||||||
|
isBuy: boolean
|
||||||
|
inputLock: TradeInputLock
|
||||||
|
orderGoldAmount: number
|
||||||
|
feeInGrams: number
|
||||||
|
feeInRial: number
|
||||||
|
payablePrice: number
|
||||||
|
unitPriceRial: number
|
||||||
|
sellGoldDebit?: number
|
||||||
|
}): TradeConfirmBreakdown {
|
||||||
|
const {
|
||||||
|
isBuy,
|
||||||
|
inputLock,
|
||||||
|
orderGoldAmount,
|
||||||
|
feeInGrams,
|
||||||
|
feeInRial,
|
||||||
|
payablePrice,
|
||||||
|
unitPriceRial,
|
||||||
|
sellGoldDebit,
|
||||||
|
} = params
|
||||||
|
const hasFee = hasTradeFee(feeInGrams, feeInRial)
|
||||||
|
const rialLocked = isRialLocked(inputLock)
|
||||||
|
const weight = orderGoldAmount
|
||||||
|
|
||||||
|
if (!isBuy) {
|
||||||
|
if (rialLocked) {
|
||||||
|
const debit = sellGoldDebit ?? parseFloat((weight + feeInGrams).toFixed(4))
|
||||||
|
const goldRows: TradeConfirmRow[] = hasFee
|
||||||
|
? [
|
||||||
|
{ label: 'وزن معادل مبلغ', gram: weight },
|
||||||
|
{ label: 'کارمزد', gram: feeInGrams, minus: true },
|
||||||
|
{ label: 'جمع کسر از کیف طلا', gram: debit, bold: true, dividerBefore: true },
|
||||||
|
]
|
||||||
|
: [{ label: 'وزن فروش', gram: weight, bold: true }]
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections: [
|
||||||
|
{ heading: 'از کیف طلا کسر میشود', rows: goldRows },
|
||||||
|
{
|
||||||
|
heading: 'به کیف پول واریز میشود',
|
||||||
|
rows: [{ label: 'مبلغ دریافتی', rial: payablePrice, bold: true }],
|
||||||
|
footnote: hasFee
|
||||||
|
? 'دقیقاً همان مبلغی که وارد کردید واریز میشود؛ کارمزد فقط از طلای کیف کسر میشود.'
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
formHint: hasFee
|
||||||
|
? `${payablePrice.toLocaleString('fa-IR')} ریال دریافت — ${formatTradeGramDisplay(debit)} گرم از کیف کسر میشود (شامل ${formatTradeGramDisplay(feeInGrams)} گرم کارمزد)`
|
||||||
|
: `${payablePrice.toLocaleString('fa-IR')} ریال دریافت`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grossSellRial = Math.round(weight * unitPriceRial)
|
||||||
|
const cashRows: TradeConfirmRow[] = hasFee
|
||||||
|
? [
|
||||||
|
{ label: 'ارزش فروش', rial: grossSellRial },
|
||||||
|
{ label: 'کارمزد', rial: feeInRial, minus: true },
|
||||||
|
{ label: 'خالص دریافتی', rial: payablePrice, bold: true, dividerBefore: true },
|
||||||
|
]
|
||||||
|
: [{ label: 'مبلغ دریافتی', rial: payablePrice, bold: true }]
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections: [
|
||||||
|
{ heading: 'از کیف طلا کسر میشود', rows: [{ label: 'وزن فروش', gram: weight, bold: true }] },
|
||||||
|
{
|
||||||
|
heading: 'به کیف پول واریز میشود',
|
||||||
|
rows: cashRows,
|
||||||
|
footnote: hasFee ? 'کارمزد از مبلغ ریالی دریافتی کم شده است.' : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
formHint: hasFee
|
||||||
|
? `${formatTradeGramDisplay(weight)} گرم فروش — ${feeInRial.toLocaleString('fa-IR')} ریال کارمزد از مبلغ دریافتی کسر میشود`
|
||||||
|
: `${formatTradeGramDisplay(weight)} گرم فروش — ${payablePrice.toLocaleString('fa-IR')} ریال به کیف پول`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rialLocked) {
|
||||||
|
const grossGram = grossGoldFromRial(payablePrice, unitPriceRial)
|
||||||
|
const feeGram = parseFloat(Math.max(0, grossGram - weight).toFixed(4))
|
||||||
|
const goldRows: TradeConfirmRow[] = hasFee
|
||||||
|
? [
|
||||||
|
{ label: 'معادل طلای خام', gram: grossGram },
|
||||||
|
{ label: 'کارمزد', gram: feeGram, minus: true },
|
||||||
|
{ label: 'خالص واریزی به کیف طلا', gram: weight, bold: true, dividerBefore: true },
|
||||||
|
]
|
||||||
|
: [{ label: 'واریزی به کیف طلا', gram: weight, bold: true }]
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'از کیف پول کسر میشود',
|
||||||
|
rows: [{ label: 'مبلغ پرداختی', rial: payablePrice, bold: true }],
|
||||||
|
footnote: 'دقیقاً همان مبلغی که وارد کردید؛ کارمزد جداگانه اضافه نمیشود.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'به کیف طلا واریز میشود',
|
||||||
|
rows: goldRows,
|
||||||
|
footnote: hasFee ? 'کارمزد از وزن طلای دریافتی کسر شده است.' : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
formHint: hasFee
|
||||||
|
? `${payablePrice.toLocaleString('fa-IR')} ریال پرداخت — ${formatTradeGramDisplay(weight)} گرم واریز (کارمزد از طلا کسر شد)`
|
||||||
|
: `${payablePrice.toLocaleString('fa-IR')} ریال پرداخت`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePriceRial = calcTradeBasePriceRial(weight, unitPriceRial)
|
||||||
|
const cashRows: TradeConfirmRow[] = hasFee
|
||||||
|
? [
|
||||||
|
{ label: `ارزش ${formatTradeGramDisplay(weight)} گرم`, rial: basePriceRial },
|
||||||
|
{ label: 'کارمزد', rial: feeInRial, plus: true },
|
||||||
|
{ label: 'جمع پرداختی', rial: payablePrice, bold: true, dividerBefore: true },
|
||||||
|
]
|
||||||
|
: [{ label: 'مبلغ پرداختی', rial: payablePrice, bold: true }]
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'از کیف پول کسر میشود',
|
||||||
|
rows: cashRows,
|
||||||
|
footnote: hasFee ? 'کارمزد به مبلغ پرداختی اضافه شده است.' : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'به کیف طلا واریز میشود',
|
||||||
|
rows: [{ label: 'دقیقاً', gram: weight, bold: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
formHint: hasFee
|
||||||
|
? `${payablePrice.toLocaleString('fa-IR')} ریال (شامل ${feeInRial.toLocaleString('fa-IR')} ریال کارمزد) — ${formatTradeGramDisplay(weight)} گرم واریز`
|
||||||
|
: `${payablePrice.toLocaleString('fa-IR')} ریال پرداخت`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export type SpecialPricingRule = {
|
||||||
|
product_id?: number
|
||||||
|
package_id: number
|
||||||
|
title: string
|
||||||
|
profit_buy: number
|
||||||
|
profit_sell: number
|
||||||
|
show_product: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpecialPricingPayload = {
|
||||||
|
enabled?: boolean
|
||||||
|
products?: SpecialPricingRule[]
|
||||||
|
board?: SpecialPricingRule | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = ref<{
|
||||||
|
enabled: boolean
|
||||||
|
byProduct: Record<number, SpecialPricingRule>
|
||||||
|
board: SpecialPricingRule | null
|
||||||
|
}>({
|
||||||
|
enabled: false,
|
||||||
|
byProduct: {},
|
||||||
|
board: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function setSpecialPricingPayload(payload?: SpecialPricingPayload | null) {
|
||||||
|
if (!payload || !payload.enabled) {
|
||||||
|
state.value = { enabled: false, byProduct: {}, board: null }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const byProduct: Record<number, SpecialPricingRule> = {}
|
||||||
|
for (const row of payload.products ?? []) {
|
||||||
|
const pid = Number(row.product_id ?? 0)
|
||||||
|
if (pid > 0) byProduct[pid] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value = {
|
||||||
|
enabled: true,
|
||||||
|
byProduct,
|
||||||
|
board: payload.board ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpecialPricingState() {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** اگر سرور قبلاً special_pricing نزده، اختلاف تابلو را روی last اعمال کن */
|
||||||
|
export function applyBoardSpecialToLastPayload<T extends Record<string, any>>(payload: T): T {
|
||||||
|
if (!payload || typeof payload !== 'object') return payload
|
||||||
|
if (payload.special_pricing) return payload
|
||||||
|
|
||||||
|
const board = state.value.board
|
||||||
|
if (!state.value.enabled || !board) return payload
|
||||||
|
|
||||||
|
const buyExtra = Number(board.profit_buy ?? 0)
|
||||||
|
const sellExtra = Number(board.profit_sell ?? 0)
|
||||||
|
if (!buyExtra && !sellExtra) return payload
|
||||||
|
|
||||||
|
const next = { ...payload } as T & Record<string, any>
|
||||||
|
if (Number(next.buy ?? 0) > 0 && buyExtra) {
|
||||||
|
next.buy = Math.max(0, Number(next.buy) + buyExtra)
|
||||||
|
}
|
||||||
|
if (Number(next.sell ?? 0) > 0 && sellExtra) {
|
||||||
|
next.sell = Math.max(0, Number(next.sell) - sellExtra)
|
||||||
|
}
|
||||||
|
if (Number(next.price_last ?? 0) > 0 && buyExtra) {
|
||||||
|
next.price_last = Math.max(0, Number(next.price_last) + buyExtra)
|
||||||
|
}
|
||||||
|
next.special_pricing = {
|
||||||
|
package_id: board.package_id,
|
||||||
|
title: board.title,
|
||||||
|
profit_buy: buyExtra,
|
||||||
|
profit_sell: sellExtra,
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
+178
-50
@@ -1,6 +1,6 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useSettingsStore } from '@/store/settingsStore'
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
|
import { isLastPriceAbsenceSettled, lastPrice, lastPriceReady } from '@/utils/price'
|
||||||
|
|
||||||
export interface StoreTradingHours {
|
export interface StoreTradingHours {
|
||||||
start: string
|
start: string
|
||||||
@@ -12,8 +12,24 @@ export interface EffectiveTradingHours {
|
|||||||
start: string
|
start: string
|
||||||
end: string
|
end: string
|
||||||
enforced: boolean
|
enforced: boolean
|
||||||
|
dayEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WeekdayKey = 'sat' | 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri'
|
||||||
|
|
||||||
|
type DaySchedule = {
|
||||||
|
enabled: boolean
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeeklySchedule = {
|
||||||
|
mode: 'uniform' | 'per_day'
|
||||||
|
days: Record<WeekdayKey, DaySchedule>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_BY_JS: WeekdayKey[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
|
||||||
|
|
||||||
const productTradingHours = ref<{ start: string; end: string } | null>(null)
|
const productTradingHours = ref<{ start: string; end: string } | null>(null)
|
||||||
const productTradingHoursVersion = ref(0)
|
const productTradingHoursVersion = ref(0)
|
||||||
|
|
||||||
@@ -54,6 +70,11 @@ function hasProductTradingWindow(start?: string | null, end?: string | null): bo
|
|||||||
return open !== close
|
return open !== close
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** بعد از merge زندهٔ تنظیمات ساعت کاری — computedهای صفحه اصلی دوباره حساب شوند */
|
||||||
|
export function touchStoreHoursSettings() {
|
||||||
|
productTradingHoursVersion.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
export function setProductTradingHours(start?: string | null, end?: string | null) {
|
export function setProductTradingHours(start?: string | null, end?: string | null) {
|
||||||
if (!hasProductTradingWindow(start, end)) {
|
if (!hasProductTradingWindow(start, end)) {
|
||||||
productTradingHours.value = null
|
productTradingHours.value = null
|
||||||
@@ -81,29 +102,77 @@ export function syncProductTradingHoursFromProducts(
|
|||||||
setProductTradingHours(null, null)
|
setProductTradingHours(null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStoreTradingHours(): StoreTradingHours {
|
function weekdayKey(now: Date): WeekdayKey {
|
||||||
const settings = useSettingsStore()
|
return WEEKDAY_BY_JS[now.getDay()] ?? 'sat'
|
||||||
const closeRaw = settings.getSettingByKey('close_price')
|
}
|
||||||
const closePriceEnabled =
|
|
||||||
closeRaw === true ||
|
|
||||||
closeRaw === 1 ||
|
|
||||||
String(closeRaw ?? 'false').trim().toLowerCase() === 'true'
|
|
||||||
|
|
||||||
return {
|
function parseWeeklySchedule(raw: unknown): WeeklySchedule | null {
|
||||||
start: normalizeTradingTimeSetting(settings.getSettingByKey('startDay'), '00:00'),
|
const text = String(raw ?? '').trim()
|
||||||
end: normalizeTradingTimeSetting(settings.getSettingByKey('endDay'), '23:59'),
|
if (!text) return null
|
||||||
closePriceEnabled,
|
try {
|
||||||
|
const parsed = JSON.parse(text) as Partial<WeeklySchedule>
|
||||||
|
if (!parsed?.days || typeof parsed.days !== 'object') return null
|
||||||
|
const mode = parsed.mode === 'per_day' ? 'per_day' : 'uniform'
|
||||||
|
const defaults = getStoreTradingHours()
|
||||||
|
const keys: WeekdayKey[] = ['sat', 'sun', 'mon', 'tue', 'wed', 'thu', 'fri']
|
||||||
|
const days = {} as Record<WeekdayKey, DaySchedule>
|
||||||
|
for (const key of keys) {
|
||||||
|
const row = parsed.days[key]
|
||||||
|
days[key] = {
|
||||||
|
enabled: row?.enabled !== false,
|
||||||
|
start: normalizeTradingTimeSetting(row?.start, defaults.start),
|
||||||
|
end: normalizeTradingTimeSetting(row?.end, defaults.end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { mode, days }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEffectiveTradingHours(): EffectiveTradingHours {
|
function isOutsideTimeWindow(now: Date, start: string, end: string): boolean {
|
||||||
|
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
|
||||||
|
const openSeconds = parseTimeToSeconds(start)
|
||||||
|
const closeSeconds = parseTimeToSeconds(end)
|
||||||
|
if (closeSeconds <= openSeconds) {
|
||||||
|
return currentSeconds < openSeconds && currentSeconds > closeSeconds
|
||||||
|
}
|
||||||
|
return currentSeconds < openSeconds || currentSeconds > closeSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoreTradingHours(): StoreTradingHours {
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
void settings.settingsRevision
|
||||||
|
return {
|
||||||
|
start: normalizeTradingTimeSetting(settings.getSettingByKey('startDay'), '00:00'),
|
||||||
|
end: normalizeTradingTimeSetting(settings.getSettingByKey('endDay'), '23:59'),
|
||||||
|
closePriceEnabled: settings.isSettingTruthy('close_price'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveTradingHours(now = new Date()): EffectiveTradingHours {
|
||||||
void productTradingHoursVersion.value
|
void productTradingHoursVersion.value
|
||||||
const settings = getStoreTradingHours()
|
const settings = getStoreTradingHours()
|
||||||
|
const weekly = parseWeeklySchedule(useSettingsStore().getSettingByKey('trading_hours_by_day'))
|
||||||
|
|
||||||
|
if (settings.closePriceEnabled && weekly?.mode === 'per_day') {
|
||||||
|
const day = weekly.days[weekdayKey(now)]
|
||||||
|
if (day) {
|
||||||
|
return {
|
||||||
|
start: day.start,
|
||||||
|
end: day.end,
|
||||||
|
enforced: true,
|
||||||
|
dayEnabled: day.enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.closePriceEnabled) {
|
if (settings.closePriceEnabled) {
|
||||||
return {
|
return {
|
||||||
start: settings.start,
|
start: settings.start,
|
||||||
end: settings.end,
|
end: settings.end,
|
||||||
enforced: true,
|
enforced: true,
|
||||||
|
dayEnabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +181,7 @@ export function getEffectiveTradingHours(): EffectiveTradingHours {
|
|||||||
start: productTradingHours.value.start,
|
start: productTradingHours.value.start,
|
||||||
end: productTradingHours.value.end,
|
end: productTradingHours.value.end,
|
||||||
enforced: true,
|
enforced: true,
|
||||||
|
dayEnabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,16 +189,40 @@ export function getEffectiveTradingHours(): EffectiveTradingHours {
|
|||||||
start: settings.start,
|
start: settings.start,
|
||||||
end: settings.end,
|
end: settings.end,
|
||||||
enforced: false,
|
enforced: false,
|
||||||
|
dayEnabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isStoreClosedBySchedule(now = new Date()): boolean {
|
/** خارج از ساعت کاری سراسری — فقط وقتی close_price روشن باشد (صفحه اصلی) */
|
||||||
const { start, end, enforced } = getEffectiveTradingHours()
|
export function isOutsideGlobalStoreHours(now = new Date()): boolean {
|
||||||
if (!enforced) {
|
const settings = getStoreTradingHours()
|
||||||
|
if (!settings.closePriceEnabled) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return isOutsideStoreTradingHours(now, start, end)
|
const weekly = parseWeeklySchedule(useSettingsStore().getSettingByKey('trading_hours_by_day'))
|
||||||
|
if (weekly?.mode === 'per_day') {
|
||||||
|
const day = weekly.days[weekdayKey(now)]
|
||||||
|
if (day && !day.enabled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (day) {
|
||||||
|
return isOutsideTimeWindow(now, day.start, day.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isOutsideTimeWindow(now, settings.start, settings.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStoreClosedBySchedule(now = new Date()): boolean {
|
||||||
|
const { start, end, enforced, dayEnabled } = getEffectiveTradingHours(now)
|
||||||
|
if (!enforced) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!dayEnabled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isOutsideTimeWindow(now, start, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isOutsideStoreTradingHours(
|
export function isOutsideStoreTradingHours(
|
||||||
@@ -136,20 +230,14 @@ export function isOutsideStoreTradingHours(
|
|||||||
start?: string,
|
start?: string,
|
||||||
end?: string,
|
end?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
const hours = start != null && end != null
|
if (start != null && end != null) {
|
||||||
? { start, end }
|
return isOutsideTimeWindow(now, start, end)
|
||||||
: getEffectiveTradingHours()
|
}
|
||||||
|
return isStoreClosedBySchedule(now)
|
||||||
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
|
|
||||||
const openSeconds = parseTimeToSeconds(hours.start)
|
|
||||||
const closeSeconds = parseTimeToSeconds(hours.end)
|
|
||||||
|
|
||||||
return currentSeconds < openSeconds || currentSeconds > closeSeconds
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasLivePriceQuote(): boolean {
|
export function hasLivePriceQuote(): boolean {
|
||||||
const p = lastPrice.value
|
return hasShowableHomePrice(lastPrice.value)
|
||||||
return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isStoreClosed(): boolean {
|
export function isStoreClosed(): boolean {
|
||||||
@@ -199,41 +287,81 @@ export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست'
|
|||||||
|
|
||||||
/** سوییچ: خارج از ساعت کاری هم قیمت صفحه اصلی نشان داده شود */
|
/** سوییچ: خارج از ساعت کاری هم قیمت صفحه اصلی نشان داده شود */
|
||||||
export function shouldShowHomePriceWhenClosed(): boolean {
|
export function shouldShowHomePriceWhenClosed(): boolean {
|
||||||
const settings = useSettingsStore()
|
return useSettingsStore().isSettingTruthy('show_home_price_when_closed')
|
||||||
return settings.isSettingTruthy('show_home_price_when_closed')
|
}
|
||||||
|
|
||||||
|
export function shouldShowLastSavedPrice(): boolean {
|
||||||
|
return useSettingsStore().isSettingTruthy('show_last_price')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فقط این منابع «آخرین قیمت ذخیرهشده» هستند؛ باقی price_source مظنهٔ زنده است */
|
||||||
|
const STORED_PRICE_SOURCES = ['database_last']
|
||||||
|
|
||||||
|
function hasShowableHomePrice(p = lastPrice.value): boolean {
|
||||||
|
if (!p) return false
|
||||||
|
return Number(p.buy) > 0 || Number(p.sell) > 0 || Number(p.price_last) > 0 || Number(p.price) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** قیمت از مرجع زنده آمده یا از آخرین ردیف ذخیرهشده */
|
||||||
|
function hasLiveQuote(p = lastPrice.value): boolean {
|
||||||
|
if (!hasShowableHomePrice(p)) return false
|
||||||
|
|
||||||
|
const source = String(p?.price_source ?? '').trim().toLowerCase()
|
||||||
|
if (source) return !STORED_PRICE_SOURCES.includes(source)
|
||||||
|
|
||||||
|
return (
|
||||||
|
Number(p?.montzar ?? 0) > 0
|
||||||
|
|| Number(p?.zaarbox ?? 0) > 0
|
||||||
|
|| Number(p?.main_product ?? 0) > 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* وضعیت نمایش قیمت:
|
* وضعیت نمایش قیمت در صفحه اصلی — همان منطق بکاند:
|
||||||
* - closed → فروشگاه بسته است (ساعت کاری، سیگنال سرور، یا نبود مظنه)
|
* خارج از ساعت کاری (close_price روشن) → show_home_price_when_closed روشن: آخرین قیمت؛ خاموش: closed
|
||||||
* - open → نمایش عادی قیمت
|
* داخل ساعت کاری → مظنه زنده: open ؛ وگرنه فقط با show_last_price آخرین قیمت؛ وگرنه no_quote
|
||||||
*
|
|
||||||
* اگر show_home_price_when_closed روشن باشد و قیمت موجود باشد، بهجای closed قیمت نشان داده میشود.
|
|
||||||
*/
|
*/
|
||||||
export function getPriceDisplayState(): PriceDisplayState {
|
export function getPriceDisplayState(): PriceDisplayState {
|
||||||
void productTradingHoursVersion.value
|
void productTradingHoursVersion.value
|
||||||
|
void useSettingsStore().settingsRevision
|
||||||
const p = lastPrice.value
|
const p = lastPrice.value
|
||||||
const showWhenClosed = shouldShowHomePriceWhenClosed()
|
|
||||||
|
|
||||||
if (p?.status === 'closed' || p?.closed === true) {
|
const serverStatus = p?.status
|
||||||
if (showWhenClosed && hasLivePriceQuote()) {
|
// اگر عدد قیمت هست، no_quote لحظهای سرور را جدی نگیر (race با تیک بعدی)
|
||||||
return 'open'
|
if (serverStatus === 'no_quote' && hasShowableHomePrice(p)) {
|
||||||
}
|
// fall through به منطق کلاینت
|
||||||
return 'closed'
|
} else if (serverStatus === 'open' || serverStatus === 'closed' || serverStatus === 'no_quote') {
|
||||||
|
return serverStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isStoreClosed()) {
|
if (isOutsideGlobalStoreHours()) {
|
||||||
if (showWhenClosed && hasLivePriceQuote()) {
|
if (!shouldShowHomePriceWhenClosed()) return 'closed'
|
||||||
return 'open'
|
return hasShowableHomePrice(p) ? 'open' : 'no_quote'
|
||||||
}
|
|
||||||
return 'closed'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasLivePriceQuote()) {
|
if (hasLiveQuote(p)) return 'open'
|
||||||
return 'closed'
|
if (shouldShowLastSavedPrice() && hasShowableHomePrice(p)) return 'open'
|
||||||
}
|
if (hasShowableHomePrice(p)) return 'open'
|
||||||
|
|
||||||
return 'open'
|
return 'no_quote'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اسکلتون تا وقتی قیمت قابلنمایش برسد، یا وضعیت بسته قطعی شود،
|
||||||
|
* یا بعد از اولین پاسخ خالی پنجرهٔ settle تمام شود (مظنه واقعاً نیست).
|
||||||
|
*/
|
||||||
|
export function isHomeQuotePending(): boolean {
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
void lastPriceReady.value
|
||||||
|
void lastPrice.value
|
||||||
|
void isLastPriceAbsenceSettled()
|
||||||
|
void settings.loaded
|
||||||
|
void settings.settingsRevision
|
||||||
|
|
||||||
|
if (!settings.loaded || !lastPriceReady.value) return true
|
||||||
|
if (hasShowableHomePrice(lastPrice.value)) return false
|
||||||
|
if (lastPrice.value?.status === 'closed') return false
|
||||||
|
return !isLastPriceAbsenceSettled()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {
|
export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* نگاشت منو/مسیر اپ کاربر → کلید دسترسی سوپرادمین.
|
||||||
|
* مقدار false/غایب = مخفی (پیشفرض امن).
|
||||||
|
*/
|
||||||
|
export const ROUTE_FEATURE_SETTING: Record<string, string> = {
|
||||||
|
fund: 'enable_product_funds',
|
||||||
|
fund_info: 'enable_product_funds',
|
||||||
|
fund_add: 'enable_product_funds',
|
||||||
|
piggy: 'enable_product_funds',
|
||||||
|
earnMoney: 'has_page_earn_money',
|
||||||
|
giftCard: 'has_page_gift_card',
|
||||||
|
notifyTrigger: 'has_page_price_trigger',
|
||||||
|
appNotifications: 'has_page_notification',
|
||||||
|
conditionalOrder: 'enable_conditional_order',
|
||||||
|
permissions: 'permision__enabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MENU_ID_FEATURE_SETTING: Record<string, string> = {
|
||||||
|
fund: 'enable_product_funds',
|
||||||
|
piggy: 'enable_product_funds',
|
||||||
|
earnMoney: 'has_page_earn_money',
|
||||||
|
giftCard: 'has_page_gift_card',
|
||||||
|
notify: 'has_page_price_trigger',
|
||||||
|
notifyTrigger: 'has_page_price_trigger',
|
||||||
|
conditionalOrder: 'enable_conditional_order',
|
||||||
|
permissions: 'permision__enabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function featureSettingForRoute(routeName: string | null | undefined): string | null {
|
||||||
|
if (!routeName) return null
|
||||||
|
return ROUTE_FEATURE_SETTING[String(routeName)] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function featureSettingForMenuId(id: string | null | undefined): string | null {
|
||||||
|
if (!id) return null
|
||||||
|
return MENU_ID_FEATURE_SETTING[String(id)] ?? null
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getSettings } from '@/api/configuration'
|
|
||||||
import type { DepositGuideVars } from '@/utils/depositGuideText'
|
import type { DepositGuideVars } from '@/utils/depositGuideText'
|
||||||
import {
|
import {
|
||||||
DEFAULT_DEPOSIT_GUIDE_TEXT,
|
DEFAULT_DEPOSIT_GUIDE_TEXT,
|
||||||
@@ -32,6 +31,7 @@ export interface SupportMessengerSettings {
|
|||||||
depositSupportMessageText?: string
|
depositSupportMessageText?: string
|
||||||
depositAccountHolder?: string
|
depositAccountHolder?: string
|
||||||
depositCardNumber?: string
|
depositCardNumber?: string
|
||||||
|
depositAccountNumber?: string
|
||||||
depositSheba?: string
|
depositSheba?: string
|
||||||
depositSmsSupportNumber?: string
|
depositSmsSupportNumber?: string
|
||||||
}
|
}
|
||||||
@@ -175,6 +175,7 @@ export function getDepositGuideVarsFromSupportSettings(
|
|||||||
return {
|
return {
|
||||||
accountHolder: settings.depositAccountHolder,
|
accountHolder: settings.depositAccountHolder,
|
||||||
cardNumber: settings.depositCardNumber,
|
cardNumber: settings.depositCardNumber,
|
||||||
|
accountNumber: settings.depositAccountNumber,
|
||||||
sheba: settings.depositSheba,
|
sheba: settings.depositSheba,
|
||||||
customerName: getCustomerNameFromUser(),
|
customerName: getCustomerNameFromUser(),
|
||||||
}
|
}
|
||||||
@@ -367,10 +368,8 @@ export async function loadSupportMessengerFromSettings(forceSettingsRefresh = fa
|
|||||||
await settingsStore.fetchSettings()
|
await settingsStore.fetchSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getSettings()
|
const settings = settingsStore.settings
|
||||||
if (!settings || !Array.isArray(settings)) return
|
if (!settings?.length) return
|
||||||
|
|
||||||
settingsStore.mergeSettings(settings)
|
|
||||||
|
|
||||||
supportMessengerSettings.value = {
|
supportMessengerSettings.value = {
|
||||||
messengers: resolveSupportMessengersFromSettings(settings),
|
messengers: resolveSupportMessengersFromSettings(settings),
|
||||||
@@ -380,6 +379,7 @@ export async function loadSupportMessengerFromSettings(forceSettingsRefresh = fa
|
|||||||
depositSupportMessageText: getSupportSettingValue(settings, 'deposit_support_message_text'),
|
depositSupportMessageText: getSupportSettingValue(settings, 'deposit_support_message_text'),
|
||||||
depositAccountHolder: getSupportSettingValue(settings, 'deposit_account_holder'),
|
depositAccountHolder: getSupportSettingValue(settings, 'deposit_account_holder'),
|
||||||
depositCardNumber: getSupportSettingValue(settings, 'deposit_card_number'),
|
depositCardNumber: getSupportSettingValue(settings, 'deposit_card_number'),
|
||||||
|
depositAccountNumber: getSupportSettingValue(settings, 'deposit_account_number'),
|
||||||
depositSheba: getSupportSettingValue(settings, 'deposit_sheba'),
|
depositSheba: getSupportSettingValue(settings, 'deposit_sheba'),
|
||||||
depositSmsSupportNumber: getSupportSettingValue(settings, 'deposit_sms_support_number'),
|
depositSmsSupportNumber: getSupportSettingValue(settings, 'deposit_sms_support_number'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type { AppSetting } from '@/store/settingsStore'
|
||||||
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
|
|
||||||
|
export const TRACKING_SYSTEMS_KEY = 'tracking_systems'
|
||||||
|
export const TRACKING_SYSTEMS_ENABLED_KEY = 'tracking_systems_enabled'
|
||||||
|
|
||||||
|
export interface TrackingSystemSite {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let trackingSettingsCache: AppSetting[] | null = null
|
||||||
|
let trackingSettingsPromise: Promise<AppSetting[]> | null = null
|
||||||
|
|
||||||
|
export function normalizeSettingsResponse(data: unknown): AppSetting[] {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data as AppSetting[]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
const record = data as Record<string, unknown>
|
||||||
|
const nested = record.data ?? record.settings ?? record.rows
|
||||||
|
if (Array.isArray(nested)) {
|
||||||
|
return nested as AppSetting[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSettingValue(settings: AppSetting[], key: string): unknown {
|
||||||
|
const setting = settings.find((item) => String(item?.key ?? '').trim() === key)
|
||||||
|
if (!setting) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return setting.value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSettingTruthy(value: unknown): boolean {
|
||||||
|
if (value === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(value ?? '').trim().toLowerCase()
|
||||||
|
return normalized === 'true' || normalized === '1' || normalized === 'yes'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrackingSystems(raw: unknown): TrackingSystemSite[] {
|
||||||
|
let value: unknown = raw
|
||||||
|
|
||||||
|
for (let depth = 0; depth < 2; depth += 1) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
value = JSON.parse(trimmed)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
const id = String(record.id ?? '').trim()
|
||||||
|
const name = String(record.name ?? record.title ?? '').trim()
|
||||||
|
const url = String(record.url ?? record.link ?? '').trim()
|
||||||
|
|
||||||
|
if (!name || !url) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id || `${name}-${url}`,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is TrackingSystemSite => item != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTrackingSystems(settings: AppSetting[]): TrackingSystemSite[] {
|
||||||
|
const raw = findSettingValue(settings, TRACKING_SYSTEMS_KEY)
|
||||||
|
return parseTrackingSystems(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTrackingSystemsEnabled(settings: AppSetting[]): boolean {
|
||||||
|
const raw = findSettingValue(settings, TRACKING_SYSTEMS_ENABLED_KEY)
|
||||||
|
return isSettingTruthy(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTrackingSettings(force = false): Promise<AppSetting[]> {
|
||||||
|
if (!force && trackingSettingsCache) {
|
||||||
|
return trackingSettingsCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && trackingSettingsPromise) {
|
||||||
|
return trackingSettingsPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
trackingSettingsPromise = (async () => {
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await settingsStore.fetchSettings(force)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = [...settingsStore.settings]
|
||||||
|
trackingSettingsCache = settings
|
||||||
|
return settings
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error)
|
||||||
|
trackingSettingsCache = []
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
trackingSettingsPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return trackingSettingsPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetTrackingSettingsCache() {
|
||||||
|
trackingSettingsCache = null
|
||||||
|
trackingSettingsPromise = null
|
||||||
|
}
|
||||||
+45
-8
@@ -7,6 +7,10 @@ import { findStoredAuthToken } from '@/utils/authToken';
|
|||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import {get} from 'lodash'
|
import {get} from 'lodash'
|
||||||
import {message} from "ant-design-vue";
|
import {message} from "ant-design-vue";
|
||||||
|
import {
|
||||||
|
hideServerUnavailableOverlay,
|
||||||
|
showServerUnavailableOverlay,
|
||||||
|
} from '@/utils/serverUnavailable'
|
||||||
|
|
||||||
interface Meta {
|
interface Meta {
|
||||||
errorMessage?: 'message' | 'notification' | 'toast' | 'none'
|
errorMessage?: 'message' | 'notification' | 'toast' | 'none'
|
||||||
@@ -71,6 +75,7 @@ function scheduleSessionVerify() {
|
|||||||
meta: { errorMessage: 'none' },
|
meta: { errorMessage: 'none' },
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
showPleaseLoginAgain()
|
||||||
await logoutToLogin()
|
await logoutToLogin()
|
||||||
} finally {
|
} finally {
|
||||||
sessionVerifyInFlight = false
|
sessionVerifyInFlight = false
|
||||||
@@ -100,10 +105,7 @@ _axios.interceptors.request.use(
|
|||||||
)
|
)
|
||||||
_axios.interceptors.response.use(
|
_axios.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
//console.log('this is response')
|
hideServerUnavailableOverlay()
|
||||||
// Any status code that lie within the range of 2xx cause this function to trigger
|
|
||||||
// Do something with response data
|
|
||||||
//console.log('this is fulfilled on interceptors.response', response.data)
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
async function (error) {
|
async function (error) {
|
||||||
@@ -115,6 +117,7 @@ _axios.interceptors.response.use(
|
|||||||
if (hadAuth) {
|
if (hadAuth) {
|
||||||
const path = requestPath(config)
|
const path = requestPath(config)
|
||||||
if (path.includes('/customer/me')) {
|
if (path.includes('/customer/me')) {
|
||||||
|
showPleaseLoginAgain()
|
||||||
await logoutToLogin()
|
await logoutToLogin()
|
||||||
} else {
|
} else {
|
||||||
scheduleSessionVerify()
|
scheduleSessionVerify()
|
||||||
@@ -123,34 +126,68 @@ _axios.interceptors.response.use(
|
|||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response?.status === 503) {
|
||||||
|
showServerUnavailableOverlay(SERVER_ERROR)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldShowApiError(config)) {
|
if (!shouldShowApiError(config)) {
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiMessage = extractApiErrorMessage(response?.data)
|
const apiMessage = extractApiErrorMessage(response?.data)
|
||||||
|
const status = Number(response?.status ?? 0)
|
||||||
|
|
||||||
if (response?.status === 403) {
|
if (response?.status === 403) {
|
||||||
showApiError(apiMessage || 'شما دسترسی لازم برای انجام این عملیات را ندارید', config)
|
showApiError(
|
||||||
|
withHttpStatus(apiMessage || 'شما دسترسی لازم برای انجام این عملیات را ندارید', 403),
|
||||||
|
config,
|
||||||
|
)
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response?.status === 422) {
|
if (response?.status === 422) {
|
||||||
if (apiMessage) {
|
if (apiMessage) {
|
||||||
showApiError(apiMessage, config)
|
showApiError(withHttpStatus(apiMessage, 422), config)
|
||||||
|
} else {
|
||||||
|
showApiError(withHttpStatus('خطا در اعتبارسنجی', 422), config)
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
showApiError(apiMessage || DEFAULT_LOAD_ERROR, config)
|
if (status >= 500) {
|
||||||
|
showApiError(withHttpStatus(SERVER_ERROR, status), config)
|
||||||
|
} else {
|
||||||
|
showApiError(withHttpStatus(apiMessage || DEFAULT_LOAD_ERROR, status), config)
|
||||||
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
showApiError(DEFAULT_LOAD_ERROR, config)
|
showApiError(NETWORK_ERROR, config)
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
const DEFAULT_LOAD_ERROR = 'خطا در بارگذاری اطلاعات'
|
const DEFAULT_LOAD_ERROR = 'خطا در بارگذاری اطلاعات'
|
||||||
|
const SERVER_ERROR = 'خطا در برقراری ارتباط با سرور'
|
||||||
|
const NETWORK_ERROR = 'لطفا اینترنت خود را بررسی کنید و مجددا تلاش کنید'
|
||||||
|
const LOGIN_AGAIN = 'لطفا مجددا وارد شوید'
|
||||||
|
let lastAuthToastAt = 0
|
||||||
|
|
||||||
|
function showPleaseLoginAgain() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastAuthToastAt < 2500) return
|
||||||
|
lastAuthToastAt = now
|
||||||
|
message.error({
|
||||||
|
content: LOGIN_AGAIN,
|
||||||
|
key: 'please-login-again',
|
||||||
|
duration: 3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function withHttpStatus(message: string, status: number): string {
|
||||||
|
return `${message} (${status})`
|
||||||
|
}
|
||||||
|
|
||||||
function extractApiErrorMessage(data: unknown): string {
|
function extractApiErrorMessage(data: unknown): string {
|
||||||
if (!data || typeof data !== 'object') {
|
if (!data || typeof data !== 'object') {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export function getDefaultAboutSoftwareText(projectName: string) {
|
||||||
|
const name = projectName.trim() || 'این نرمافزار'
|
||||||
|
return `${name} بستری امن و آنلاین برای خرید و فروش طلا فراهم کرده تا کاربران بتوانند سرمایههای خود را بهسادگی مدیریت کنند.\n\nهدف ما ارائه تجربهای سریع، شفاف و کاربرپسند در معاملات طلا است.`
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeHtml(value: string) {
|
||||||
|
return /<\/?[a-z][\s\S]*>/i.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AboutSoftwareBody =
|
||||||
|
| { kind: 'html'; html: string }
|
||||||
|
| { kind: 'text'; text: string }
|
||||||
|
|
||||||
|
/** اولویت با تنظیم about_software؛ در صورت خالی بودن، متن پیشفرض */
|
||||||
|
export function resolveAboutSoftwareBody(
|
||||||
|
raw: unknown,
|
||||||
|
projectName: string,
|
||||||
|
): AboutSoftwareBody {
|
||||||
|
const value = String(raw ?? '').trim()
|
||||||
|
if (!value) {
|
||||||
|
return { kind: 'text', text: getDefaultAboutSoftwareText(projectName) }
|
||||||
|
}
|
||||||
|
if (looksLikeHtml(value)) {
|
||||||
|
const stripped = value
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
if (!stripped) {
|
||||||
|
return { kind: 'text', text: getDefaultAboutSoftwareText(projectName) }
|
||||||
|
}
|
||||||
|
return { kind: 'html', html: value }
|
||||||
|
}
|
||||||
|
return { kind: 'text', text: value }
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div class="about-software-page-root">
|
||||||
|
<div v-if="isDesktop" class="daboutus">
|
||||||
|
<div class="daboutus__container">
|
||||||
|
<header class="daboutus-topbar">
|
||||||
|
<button type="button" class="daboutus-topbar__back" @click="router.back()">
|
||||||
|
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
|
||||||
|
<span>بازگشت</span>
|
||||||
|
</button>
|
||||||
|
<div class="daboutus-topbar__meta">
|
||||||
|
<p class="daboutus-topbar__eyebrow">اطلاعات</p>
|
||||||
|
<h1 class="daboutus-topbar__title">درباره نرمافزار</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="daboutus-surface daboutus-main">
|
||||||
|
<div class="daboutus-main__intro">
|
||||||
|
<div class="daboutus-main__logo">
|
||||||
|
<img :src="config.config?.avatar" alt="" class="daboutus-main__avatar" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="daboutus-main__project">{{ projectName }}</h2>
|
||||||
|
<p class="daboutus-main__tagline">معرفی نرمافزار و خدمات</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="daboutus-main__scroll">
|
||||||
|
<div class="daboutus-content about-software-card">
|
||||||
|
<div
|
||||||
|
v-if="body.kind === 'html'"
|
||||||
|
class="about-software-body about-software-html"
|
||||||
|
v-html="body.html"
|
||||||
|
/>
|
||||||
|
<div v-else class="about-software-body whitespace-pre-line">
|
||||||
|
{{ body.text }}
|
||||||
|
</div>
|
||||||
|
<div class="about-software-enamad">
|
||||||
|
<EnamadBadge size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mobile-info-page">
|
||||||
|
<div class="mobile-info-page__bg" aria-hidden="true">
|
||||||
|
<img :src="img" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="wrap-fix mobile-top-chrome mobile-info-page__header bg-white px-3 pb-3 text-black font-bold drop-shadow flex">
|
||||||
|
<div class="cursor-pointer" @click="router.back()">
|
||||||
|
<BasicIcon icon="vuesax-linear:arrow-right-1" />
|
||||||
|
</div>
|
||||||
|
<div class="mr-2">درباره نرمافزار</div>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-info-page__main about-software-main">
|
||||||
|
<div class="border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] about-software-card mobile-content-box">
|
||||||
|
<div class="mobile-content-box__scroll about-software-card__scroll">
|
||||||
|
<img
|
||||||
|
:src="config.config?.avatar"
|
||||||
|
class="about-software-avatar"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<h2 class="text-sm font-bold mb-2">درباره نرمافزار</h2>
|
||||||
|
<div
|
||||||
|
v-if="body.kind === 'html'"
|
||||||
|
class="about-software-body about-software-html"
|
||||||
|
v-html="body.html"
|
||||||
|
/>
|
||||||
|
<div v-else class="about-software-body whitespace-pre-line">
|
||||||
|
{{ body.text }}
|
||||||
|
</div>
|
||||||
|
<div class="about-software-enamad">
|
||||||
|
<EnamadBadge size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import EnamadBadge from '@/components/EnamadBadge.vue'
|
||||||
|
import img from '@/assets/img/user_ly1.svg'
|
||||||
|
import router from '@/router'
|
||||||
|
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||||
|
import { useConfigStore } from '@/store/configStore'
|
||||||
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
|
import { resolveAboutSoftwareBody } from './aboutSoftwareContent'
|
||||||
|
|
||||||
|
const isDesktop = useDesktopBreakpoint()
|
||||||
|
const config = useConfigStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
const projectName = computed(
|
||||||
|
() => config.config?.name_fa || config.config?.name || '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const body = computed(() => {
|
||||||
|
void settingsStore.settings
|
||||||
|
return resolveAboutSoftwareBody(
|
||||||
|
settingsStore.getSettingByKey('about_software'),
|
||||||
|
projectName.value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void settingsStore.fetchSettings()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.about-software-page-root {
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-main {
|
||||||
|
padding-right: 1rem !important;
|
||||||
|
padding-left: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-card {
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-card__scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-avatar {
|
||||||
|
width: 6.5rem;
|
||||||
|
margin: 0.75rem auto 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-body {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
text-align: justify;
|
||||||
|
text-justify: inter-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-html :deep(p) {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
text-align: justify;
|
||||||
|
text-justify: inter-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-html :deep(p + p) {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-html :deep(ul) {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding-right: 1.1rem;
|
||||||
|
list-style: disc;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-html :deep(li + li) {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-software-enamad {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -155,39 +155,20 @@
|
|||||||
|
|
||||||
<BasicActionSheet v-model:show="confirmAction">
|
<BasicActionSheet v-model:show="confirmAction">
|
||||||
<div class="font-bold text-lg"></div>
|
<div class="font-bold text-lg"></div>
|
||||||
<div class="w-full rounded-lg bg-gray-50 p-3">
|
<TradeFeeConfirmSummary
|
||||||
<div class="flex justify-between ">
|
:is-buy="isBuy"
|
||||||
<div>وزن:</div>
|
input-lock="gram"
|
||||||
<div class="flex items-center">
|
:display-trade-gold-amount="form.goldValue ?? 0"
|
||||||
<div class="font-bold">{{ form.goldValue }}</div>
|
weight-unit="گرم"
|
||||||
<div class="text-xs mr-1">گرم</div>
|
:unit-price-rial="basePriceValue ?? unitPrice"
|
||||||
</div>
|
:fee-in-grams="feeInGrams"
|
||||||
</div>
|
:equivalent-price="equivalentPrice"
|
||||||
<div class="flex justify-between mt-3">
|
:payable-price="payablePrice"
|
||||||
<div>مظنه:</div>
|
:order-gold-amount="form.goldValue ?? 0"
|
||||||
<div class="flex items-center">
|
:fee-in-rial="feeInRial"
|
||||||
<div class="font-bold">{{ usePriceWithoutType(Math.round(basePriceValue ?? 0)) }}</div>
|
:sell-gold-debit="sellGoldDebit"
|
||||||
<div class="text-xs mr-1">ریال</div>
|
/>
|
||||||
</div>
|
<BasicButton class="mb-20 mt-5 w-full" type="primary" :disabled="loading" @click="ok">ثبت نهایی</BasicButton>
|
||||||
</div>
|
|
||||||
<div class="flex justify-between mt-3">
|
|
||||||
<div>کارمزد:</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="font-bold text-xl">{{ feeInGrams }}</div>
|
|
||||||
<div class="text-xs mr-1">گرم</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div >(معادل {{ equivalentPrice }} ریال)</div>
|
|
||||||
<div class="flex justify-between mt-3">
|
|
||||||
<div>{{ isBuy ? 'قیمت قابل پرداخت:' : 'مبلغ دریافتی:' }}</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="font-bold">{{ usePriceWithoutType(payablePrice.toFixed(0)) }}</div>
|
|
||||||
<div class="text-xs mr-1">ریال</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<BasicButton class="mb-20 mt-5 w-full" type="primary" @click="ok(); confirmAction= false">ثبت نهایی</BasicButton>
|
|
||||||
</BasicActionSheet>
|
</BasicActionSheet>
|
||||||
<van-dialog v-model:show="noCompleteVisible" class="completeButtonStyle" confirm-button-text="تکمیل اطلاعات"
|
<van-dialog v-model:show="noCompleteVisible" class="completeButtonStyle" confirm-button-text="تکمیل اطلاعات"
|
||||||
show-confirm-button @confirm="router.push({name: 'user',query:{showEdit: true}})">
|
show-confirm-button @confirm="router.push({name: 'user',query:{showEdit: true}})">
|
||||||
@@ -222,6 +203,7 @@ import { getLastPrice } from '@/api/home'
|
|||||||
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
||||||
|
import TradeFeeConfirmSummary from '@/components/TradeFeeConfirmSummary.vue'
|
||||||
import { justNumber } from '@/utils/inputRules'
|
import { justNumber } from '@/utils/inputRules'
|
||||||
import GoldSkeleton from '@/views/base/gold/components/GoldSkeleton.vue'
|
import GoldSkeleton from '@/views/base/gold/components/GoldSkeleton.vue'
|
||||||
import GoldLoading from '@/components/GoldLoading.vue'
|
import GoldLoading from '@/components/GoldLoading.vue'
|
||||||
|
|||||||
@@ -65,10 +65,12 @@ export function useEducatePage() {
|
|||||||
formRef.value?.validateFields().then(async () => {
|
formRef.value?.validateFields().then(async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await updateMe(userFormState.value)
|
const res: any = await updateMe(userFormState.value)
|
||||||
await userStore.getMyInfo()
|
await userStore.getMyInfo()
|
||||||
editPassVisible.value = false
|
editPassVisible.value = false
|
||||||
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
|
message.success(
|
||||||
|
res?.message || 'اطلاعات شما با موفقیت به روز رسانی شد',
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gold-embed {
|
.gold-embed {
|
||||||
max-height: min(70vh, 35rem);
|
max-height: min(78vh, 40rem);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+57
-108
@@ -18,33 +18,45 @@
|
|||||||
<div :class="panelClass">
|
<div :class="panelClass">
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'!overflow-y-scroll bg-white py-3 text-primary font-bold flex justify-evenly',
|
'bg-white px-3 pt-3 pb-1',
|
||||||
isStandaloneMobile ? 'mpage-subheader wrap-fix' : '',
|
isStandaloneMobile ? 'mpage-subheader wrap-fix' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<!-- <div @click="isBuy = false" class="w-full text-center" :class="!isBuy?'text-primary':'text-gray-400'">خرید طلا</div>
|
<div class="flex w-full p-1 items-center border border-gray-200 rounded-full bg-white gap-1">
|
||||||
<div @click="isBuy = true" class="w-full text-center" :class="isBuy?'text-primary':'text-gray-400'">فروش طلا</div>-->
|
<div
|
||||||
<div class="flex justify-evenly w-full px-2 h-11 items-center bg-gray-50 relative rounded ">
|
class="w-full h-10 flex items-center justify-center font-bold rounded-full transition-all"
|
||||||
<div :class="isBuy?'text-tertiary':'text-gray-400'" class="w-full text-center z-2"
|
:class="isBuy ? 'bg-green-600 text-white shadow-sm' : 'text-green-600'"
|
||||||
@click.stop="setIsBuy(true) ">
|
@click.stop="setIsBuy(true)"
|
||||||
خرید {{ tradeProductLabel }}
|
:style="!isProductBuyOpen(props?.ProductItem) ? 'pointer-events: none; opacity: 0.35;' : ''"
|
||||||
|
>
|
||||||
|
خرید
|
||||||
</div>
|
</div>
|
||||||
<div :class="!isBuy?'text-tertiary':'text-gray-400'" class="w-full text-center z-2"
|
<div
|
||||||
@click.stop="setIsBuy(false) ">
|
class="w-full h-10 flex items-center justify-center font-bold rounded-full transition-all"
|
||||||
فروش {{ tradeProductLabel }}
|
:class="!isBuy ? 'bg-red-600 text-white shadow-sm' : 'text-red-600'"
|
||||||
|
@click.stop="setIsBuy(false)"
|
||||||
|
:style="!isProductSellOpen(props?.ProductItem) ? 'pointer-events: none; opacity: 0.35;' : ''"
|
||||||
|
>
|
||||||
|
فروش
|
||||||
</div>
|
</div>
|
||||||
<!-- <div ref="bganim" class="absolute bg-white h-8 w-1/2 rounded-lg " :style="`transform: translateX(${isbuy? '0':'-100%'}); left: ${isbuy? '0':'100%'}`"></div>-->
|
|
||||||
<div ref="bganim" :style="useStyle()"
|
|
||||||
class="absolute bg-white h-8 w-1/2 rounded-lg animate right"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userStore" :class="contentClass">
|
<div v-if="userStore" :class="contentClass">
|
||||||
<a-spin :spinning="!price">
|
<a-spin :spinning="!price">
|
||||||
<div v-if="!loading" :class="!isBuy? ' bg-red-100' : ' bg-green-100'"
|
<div v-if="!loading" :class="!isBuy? ' bg-red-100' : ' bg-green-100'"
|
||||||
class="w-full relative rounded-lg h-[88px] flex flex-col justify-center background-color-transition">
|
class="w-full relative rounded-2xl h-[88px] flex flex-col justify-center background-color-transition">
|
||||||
<img :src="img1" alt="" class="h-[88px] absolute inset-0 w-full object-cover">
|
<img :src="img1" alt="" class="h-[88px] absolute inset-0 w-full object-cover">
|
||||||
<div class="w-full items-center flex flex-col">
|
<div class="w-full items-center flex flex-col">
|
||||||
<div class="text-gray-700 font-bold">{{ !isBuy ? 'قیمت فروش' : 'قیمت خرید' }}</div>
|
<div class="flex items-center justify-center gap-2 flex-wrap px-3">
|
||||||
|
<span class="text-gray-700 font-bold">{{ !isBuy ? 'قیمت فروش' : 'قیمت خرید' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="tradeProductLabel"
|
||||||
|
class="text-sm font-extrabold px-3 py-1 rounded-full max-w-[14rem] truncate shadow-sm"
|
||||||
|
:class="!isBuy ? 'bg-red-200/90 text-red-900' : 'bg-green-200/90 text-green-900'"
|
||||||
|
>
|
||||||
|
{{ tradeProductLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div v-if="price" class="flex gap-1 mt-3 text-xs items-center">
|
<div v-if="price" class="flex gap-1 mt-3 text-xs items-center">
|
||||||
<div :class="!isBuy? 'text-red-700' : 'text-green-700'" class="font-bold text-2xl color-transition">
|
<div :class="!isBuy? 'text-red-700' : 'text-green-700'" class="font-bold text-2xl color-transition">
|
||||||
{{ usePriceWithoutType(goldPriceValue) }}
|
{{ usePriceWithoutType(goldPriceValue) }}
|
||||||
@@ -58,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
<a-form :model="form" class="!mt-5" name="basic" @finish="okConfirm()">
|
<a-form :model="form" class="!mt-5" name="basic" @finish="okConfirm()">
|
||||||
<div class="border-gray-200 border-1 rounded-xl p-5 gold-form-card relative">
|
<div class="border-gray-200 border-1 rounded-2xl p-5 gold-form-card relative">
|
||||||
<bg_main />
|
<bg_main />
|
||||||
<div class="z-100 relative">
|
<div class="z-100 relative">
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
@@ -137,7 +149,7 @@
|
|||||||
موجودی کیف پول ناکافی می باشد.
|
موجودی کیف پول ناکافی می باشد.
|
||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<div class="w-full px-4 rounded-xl py-2 border-1 bg-white gold-fee-box">
|
<div class="w-full px-4 rounded-2xl py-2 border-1 bg-white gold-fee-box">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>کارمزد:</div>
|
<div>کارمزد:</div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
@@ -162,51 +174,19 @@
|
|||||||
|
|
||||||
<BasicActionSheet v-if="!isStandaloneDesktop" v-model:show="confirmAction">
|
<BasicActionSheet v-if="!isStandaloneDesktop" v-model:show="confirmAction">
|
||||||
<div class="font-bold text-lg"></div>
|
<div class="font-bold text-lg"></div>
|
||||||
<div class="w-full rounded-lg bg-gray-50 p-3">
|
<TradeFeeConfirmSummary
|
||||||
<div class="flex justify-between ">
|
:is-buy="isBuy"
|
||||||
<div>وزن:</div>
|
:input-lock="inputLock"
|
||||||
<div class="flex items-center">
|
:display-trade-gold-amount="displayTradeGoldAmount"
|
||||||
<div class="font-bold">{{ displayTradeGoldAmount }}</div>
|
:weight-unit="weightUnit"
|
||||||
<div class="text-xs mr-1">{{ weightUnit }}</div>
|
:unit-price-rial="goldPriceValue"
|
||||||
</div>
|
:fee-in-grams="feeInGrams"
|
||||||
</div>
|
:equivalent-price="equivalentPrice"
|
||||||
<div class="flex justify-between mt-3">
|
:payable-price="payablePrice"
|
||||||
<div>مظنه:</div>
|
:order-gold-amount="orderGoldAmount"
|
||||||
<div class="flex items-center">
|
:fee-in-rial="feeInRial"
|
||||||
<div class="font-bold">{{ usePriceWithoutType(Math.round(goldPriceValue)) }}</div>
|
:sell-gold-debit="sellGoldDebit"
|
||||||
<div class="text-xs mr-1">ریال</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between mt-3">
|
|
||||||
<div>کارمزد:</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="font-bold text-xl">{{ feeInGrams }}</div>
|
|
||||||
<div class="text-xs mr-1">{{ weightUnit }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div >(معادل {{ equivalentPrice }} ریال)</div>
|
|
||||||
<div v-if="isBuy && feeDeductFromGold" class="flex justify-between mt-3">
|
|
||||||
<div>طلا دریافتی:</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="font-bold">{{ displayTradeGoldAmount }}</div>
|
|
||||||
<div class="text-xs mr-1">{{ weightUnit }} (دقیق — کارمزد از ریال)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!isBuy && feeDeductFromGold" class="flex justify-between mt-3">
|
|
||||||
<div>کسر از کیف طلا:</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="font-bold">{{ sellGoldDebit }}</div>
|
|
||||||
<div class="text-xs mr-1">{{ weightUnit }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between mt-3">
|
|
||||||
<div>{{ isBuy ? 'قیمت قابل پرداخت:' : 'مبلغ دریافتی:' }}</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="font-bold">{{ usePriceWithoutType(payablePrice.toFixed(0)) }}</div>
|
|
||||||
<div class="text-xs mr-1">ریال</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<BasicButton
|
<BasicButton
|
||||||
class="mb-20 mt-5 w-full"
|
class="mb-20 mt-5 w-full"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -224,51 +204,19 @@
|
|||||||
destroy-on-close
|
destroy-on-close
|
||||||
custom-footer
|
custom-footer
|
||||||
>
|
>
|
||||||
<div class="w-full rounded-lg bg-gray-50 p-3">
|
<TradeFeeConfirmSummary
|
||||||
<div class="flex justify-between ">
|
:is-buy="isBuy"
|
||||||
<div>وزن:</div>
|
:input-lock="inputLock"
|
||||||
<div class="flex items-center">
|
:display-trade-gold-amount="displayTradeGoldAmount"
|
||||||
<div class="font-bold">{{ displayTradeGoldAmount }}</div>
|
:weight-unit="weightUnit"
|
||||||
<div class="text-xs mr-1">{{ weightUnit }}</div>
|
:unit-price-rial="goldPriceValue"
|
||||||
</div>
|
:fee-in-grams="feeInGrams"
|
||||||
</div>
|
:equivalent-price="equivalentPrice"
|
||||||
<div class="flex justify-between mt-3">
|
:payable-price="payablePrice"
|
||||||
<div>مظنه:</div>
|
:order-gold-amount="orderGoldAmount"
|
||||||
<div class="flex items-center">
|
:fee-in-rial="feeInRial"
|
||||||
<div class="font-bold">{{ usePriceWithoutType(Math.round(goldPriceValue)) }}</div>
|
:sell-gold-debit="sellGoldDebit"
|
||||||
<div class="text-xs mr-1">ریال</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between mt-3">
|
|
||||||
<div>کارمزد:</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="font-bold text-xl">{{ feeInGrams }}</div>
|
|
||||||
<div class="text-xs mr-1">{{ weightUnit }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>(معادل {{ equivalentPrice }} ریال)</div>
|
|
||||||
<div v-if="isBuy && feeDeductFromGold" class="flex justify-between mt-3">
|
|
||||||
<div>طلا دریافتی:</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="font-bold">{{ displayTradeGoldAmount }}</div>
|
|
||||||
<div class="text-xs mr-1">{{ weightUnit }} (دقیق — کارمزد از ریال)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!isBuy && feeDeductFromGold" class="flex justify-between mt-3">
|
|
||||||
<div>کسر از کیف طلا:</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="font-bold">{{ sellGoldDebit }}</div>
|
|
||||||
<div class="text-xs mr-1">{{ weightUnit }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between mt-3">
|
|
||||||
<div>{{ isBuy ? 'قیمت قابل پرداخت:' : 'مبلغ دریافتی:' }}</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="font-bold">{{ usePriceWithoutType(payablePrice.toFixed(0)) }}</div>
|
|
||||||
<div class="text-xs mr-1">ریال</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<BasicButton class="mt-5 w-full" type="primary" :disabled="loading" @click="ok">
|
<BasicButton class="mt-5 w-full" type="primary" :disabled="loading" @click="ok">
|
||||||
ثبت نهایی
|
ثبت نهایی
|
||||||
</BasicButton>
|
</BasicButton>
|
||||||
@@ -311,6 +259,7 @@ import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
|||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
||||||
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
|
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
|
||||||
|
import TradeFeeConfirmSummary from '@/components/TradeFeeConfirmSummary.vue'
|
||||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||||
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
|
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
|
||||||
import { justNumber } from '@/utils/inputRules'
|
import { justNumber } from '@/utils/inputRules'
|
||||||
@@ -398,7 +347,7 @@ const contentClass = computed(() => {
|
|||||||
if (isStandaloneMobile.value) {
|
if (isStandaloneMobile.value) {
|
||||||
return 'px-3 mpage-content mpage-content--with-subheader pb-[calc(var(--app-bottom-chrome-height)+1rem)]'
|
return 'px-3 mpage-content mpage-content--with-subheader pb-[calc(var(--app-bottom-chrome-height)+1rem)]'
|
||||||
}
|
}
|
||||||
return 'mt-5 px-3 pb-4'
|
return 'mt-5 px-3 pb-10'
|
||||||
})
|
})
|
||||||
const isBuy = ref<boolean>(true)
|
const isBuy = ref<boolean>(true)
|
||||||
const inputLock = ref<TradeInputLock>(null)
|
const inputLock = ref<TradeInputLock>(null)
|
||||||
|
|||||||
@@ -3,7 +3,18 @@
|
|||||||
class="w-full relative mt-2 rounded-lg py-3 flex flex-col justify-center home_appear_fade_in transition-colors duration-300"
|
class="w-full relative mt-2 rounded-lg py-3 flex flex-col justify-center home_appear_fade_in transition-colors duration-300"
|
||||||
:class="containerClass"
|
:class="containerClass"
|
||||||
>
|
>
|
||||||
<template v-if="displayState !== 'open'">
|
<div
|
||||||
|
v-if="quotePending"
|
||||||
|
class="flex flex-col items-center gap-2 px-4 py-2"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label="در حال بارگذاری قیمت"
|
||||||
|
>
|
||||||
|
<div class="h-3 w-32 rounded boursecu-price-skeleton animate-pulse" />
|
||||||
|
<div class="h-6 w-48 rounded boursecu-price-skeleton animate-pulse" />
|
||||||
|
<div class="h-8 w-24 rounded-lg boursecu-price-skeleton animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="displayState !== 'open'">
|
||||||
<div class="status-box status-box--boursecu">
|
<div class="status-box status-box--boursecu">
|
||||||
<div class="status-box__icon-wrap">
|
<div class="status-box__icon-wrap">
|
||||||
<Icon
|
<Icon
|
||||||
@@ -57,12 +68,14 @@ import {
|
|||||||
STORE_CLOSED_TITLE,
|
STORE_CLOSED_TITLE,
|
||||||
getPriceDisplayState,
|
getPriceDisplayState,
|
||||||
getStoreClosedHint,
|
getStoreClosedHint,
|
||||||
|
isHomeQuotePending,
|
||||||
} from '@/utils/storeHours'
|
} from '@/utils/storeHours'
|
||||||
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
|
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const displayState = computed(() => getPriceDisplayState())
|
const displayState = computed(() => getPriceDisplayState())
|
||||||
|
const quotePending = computed(() => isHomeQuotePending())
|
||||||
|
|
||||||
const statusTitle = computed(() =>
|
const statusTitle = computed(() =>
|
||||||
displayState.value === 'closed' ? STORE_CLOSED_TITLE : NO_QUOTE_MESSAGE
|
displayState.value === 'closed' ? STORE_CLOSED_TITLE : NO_QUOTE_MESSAGE
|
||||||
@@ -76,6 +89,9 @@ const statusHint = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const containerClass = computed(() => {
|
const containerClass = computed(() => {
|
||||||
|
if (quotePending.value) {
|
||||||
|
return 'boursecu-price-box boursecu-price-box--open'
|
||||||
|
}
|
||||||
if (displayState.value !== 'open') {
|
if (displayState.value !== 'open') {
|
||||||
return 'boursecu-price-box boursecu-price-box--closed'
|
return 'boursecu-price-box boursecu-price-box--closed'
|
||||||
}
|
}
|
||||||
@@ -129,6 +145,10 @@ void price
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boursecu-price-skeleton {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.status-box {
|
.status-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<DotLottieVue
|
<DotLottieVue
|
||||||
class="w-11rem h-11rem"
|
class="w-11rem h-11rem"
|
||||||
autoplay
|
autoplay
|
||||||
src="https://lottie.host/129cf3a1-df84-4758-a856-58c133ae6013/R5RqclXA8G.json"
|
v-bind="lottieBind"
|
||||||
/>
|
/>
|
||||||
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
|
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DialogVant from '@/components/DialogVant.vue'
|
import DialogVant from '@/components/DialogVant.vue'
|
||||||
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
|
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
|
||||||
import {computed} from "vue";
|
import {computed, ref, watch} from 'vue'
|
||||||
|
import {getLottieData, lottieSrc, preloadLottieAssets} from '@/utils/lottieAssets'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -34,6 +35,27 @@ const emit = defineEmits<{
|
|||||||
confirm: []
|
confirm: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const cachedData = ref(getLottieData('logoutConfirm'))
|
||||||
|
|
||||||
|
const lottieBind = computed(() => {
|
||||||
|
if (cachedData.value) return {data: cachedData.value}
|
||||||
|
return {src: lottieSrc('logoutConfirm')}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function ensureLottie() {
|
||||||
|
if (cachedData.value) return
|
||||||
|
await preloadLottieAssets()
|
||||||
|
cachedData.value = getLottieData('logoutConfirm')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(open) => {
|
||||||
|
if (open) void ensureLottie()
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.show,
|
get: () => props.show,
|
||||||
set: (val) => emit('update:show', val)
|
set: (val) => emit('update:show', val)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getConstByKey } from '@/api/const'
|
import { getConstByKey } from '@/api/const'
|
||||||
import { useSettingsStore } from '@/store/settingsStore'
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
|
import { normalizePublicFileUrl } from '@/utils/file'
|
||||||
|
|
||||||
function parseJsonArray(raw: unknown): unknown[] {
|
function parseJsonArray(raw: unknown): unknown[] {
|
||||||
if (raw == null || raw === '') {
|
if (raw == null || raw === '') {
|
||||||
@@ -69,7 +70,14 @@ async function fetchBanners(force = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
banners.value = list.filter((banner) => (banner as { show?: boolean }).show === true)
|
banners.value = list
|
||||||
|
.filter((banner) => (banner as { show?: boolean }).show === true)
|
||||||
|
.map((banner) => {
|
||||||
|
if (!banner || typeof banner !== 'object') return banner
|
||||||
|
const row = banner as { icon?: unknown }
|
||||||
|
if (typeof row.icon !== 'string') return banner
|
||||||
|
return { ...row, icon: normalizePublicFileUrl(row.icon) ?? '' }
|
||||||
|
})
|
||||||
bannersLoaded = true
|
bannersLoaded = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading banners:', error)
|
console.error('Error loading banners:', error)
|
||||||
|
|||||||
@@ -2,11 +2,22 @@
|
|||||||
<div
|
<div
|
||||||
class="dhome-surface dhome-hero-price"
|
class="dhome-surface dhome-hero-price"
|
||||||
:class="{
|
:class="{
|
||||||
'dhome-hero-price--boursecu-open': displayState === 'open',
|
'dhome-hero-price--boursecu-open': displayState === 'open' || quotePending,
|
||||||
'dhome-hero-price--boursecu-closed': displayState !== 'open',
|
'dhome-hero-price--boursecu-closed': displayState !== 'open' && !quotePending,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template v-if="displayState !== 'open'">
|
<div
|
||||||
|
v-if="quotePending"
|
||||||
|
class="flex flex-col items-center gap-2 py-4"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label="در حال بارگذاری قیمت"
|
||||||
|
>
|
||||||
|
<div class="h-3 w-24 rounded boursecu-price-skeleton animate-pulse" />
|
||||||
|
<div class="h-4 w-32 rounded boursecu-price-skeleton animate-pulse" />
|
||||||
|
<div class="h-8 w-40 rounded boursecu-price-skeleton animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="displayState !== 'open'">
|
||||||
<div class="dhome-hero-price__closed">
|
<div class="dhome-hero-price__closed">
|
||||||
<div class="dhome-hero-price__closed-icon">
|
<div class="dhome-hero-price__closed-icon">
|
||||||
<BasicIcon
|
<BasicIcon
|
||||||
@@ -56,12 +67,14 @@ import {
|
|||||||
STORE_CLOSED_TITLE,
|
STORE_CLOSED_TITLE,
|
||||||
getPriceDisplayState,
|
getPriceDisplayState,
|
||||||
getStoreClosedHint,
|
getStoreClosedHint,
|
||||||
|
isHomeQuotePending,
|
||||||
} from '@/utils/storeHours'
|
} from '@/utils/storeHours'
|
||||||
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
|
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const displayState = computed(() => getPriceDisplayState())
|
const displayState = computed(() => getPriceDisplayState())
|
||||||
|
const quotePending = computed(() => isHomeQuotePending())
|
||||||
|
|
||||||
const statusTitle = computed(() =>
|
const statusTitle = computed(() =>
|
||||||
displayState.value === 'closed' ? STORE_CLOSED_TITLE : NO_QUOTE_MESSAGE
|
displayState.value === 'closed' ? STORE_CLOSED_TITLE : NO_QUOTE_MESSAGE
|
||||||
@@ -110,6 +123,10 @@ const percentChange = computed(() => {
|
|||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boursecu-price-skeleton {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.dhome-hero-price__closed {
|
.dhome-hero-price__closed {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ const resolvedActions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleClick(action: HomeQuickActionItem) {
|
function handleClick(action: HomeQuickActionItem) {
|
||||||
router.push(action.route)
|
if (action.handler) {
|
||||||
|
action.handler()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.route) {
|
||||||
|
router.push(action.route)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ const resolvedActions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function navigate(action: HomeQuickActionItem) {
|
function navigate(action: HomeQuickActionItem) {
|
||||||
router.push(action.route)
|
if (action.handler) {
|
||||||
|
action.handler()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.route) {
|
||||||
|
router.push(action.route)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
class="w-5rem h-5rem mt-[-1.6rem] ml-[-2rem]"
|
class="w-5rem h-5rem mt-[-1.6rem] ml-[-2rem]"
|
||||||
autoplay
|
autoplay
|
||||||
loop
|
loop
|
||||||
src="https://lottie.host/3d46ebb3-a580-41eb-ade7-b8829c32b071/PJPuSKbpze.json"
|
src="/lottie/goldBars.json"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,7 +315,7 @@
|
|||||||
<DotLottieVue
|
<DotLottieVue
|
||||||
class="w-11rem h-11rem"
|
class="w-11rem h-11rem"
|
||||||
autoplay
|
autoplay
|
||||||
src="https://lottie.host/129cf3a1-df84-4758-a856-58c133ae6013/R5RqclXA8G.json"
|
src="/lottie/logoutConfirm.json"
|
||||||
/>
|
/>
|
||||||
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
|
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getBoursecuActionIconSrc } from '@/utils/config'
|
import { getBoursecuActionIconSrc } from '@/utils/config'
|
||||||
|
import { openGoldDayCalculator } from '@/composables/useGoldDayCalculator'
|
||||||
import type { HomeQuickActionItem } from '../types'
|
import type { HomeQuickActionItem } from '../types'
|
||||||
|
|
||||||
export const boursecuQuickActions: HomeQuickActionItem[] = [
|
export const boursecuQuickActions: HomeQuickActionItem[] = [
|
||||||
@@ -44,4 +45,10 @@ export const boursecuQuickActions: HomeQuickActionItem[] = [
|
|||||||
iconSrc: getBoursecuActionIconSrc('convert'),
|
iconSrc: getBoursecuActionIconSrc('convert'),
|
||||||
route: { name: 'buyProduct' },
|
route: { name: 'buyProduct' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'calculator',
|
||||||
|
label: 'محاسبهگر',
|
||||||
|
icon: 'vuesax-linear:chart-2',
|
||||||
|
handler: () => openGoldDayCalculator(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
@@ -35,7 +35,13 @@ const resolvedActions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleClick(action: HomeQuickActionItem) {
|
function handleClick(action: HomeQuickActionItem) {
|
||||||
router.push(action.route)
|
if (action.handler) {
|
||||||
|
action.handler()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.route) {
|
||||||
|
router.push(action.route)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export interface HomeQuickActionItem {
|
|||||||
label: string
|
label: string
|
||||||
icon: string
|
icon: string
|
||||||
iconSrc?: string
|
iconSrc?: string
|
||||||
route: { name: string; query?: Record<string, string> }
|
route?: { name: string; query?: Record<string, string> }
|
||||||
|
handler?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeLayoutSectionConfig {
|
export interface HomeLayoutSectionConfig {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ import {
|
|||||||
isCountBasedProduct,
|
isCountBasedProduct,
|
||||||
} from '@/utils/productDealMethod'
|
} from '@/utils/productDealMethod'
|
||||||
import { formatGoldAmount, usePriceWithoutType } from '@/utils/usePrice'
|
import { formatGoldAmount, usePriceWithoutType } from '@/utils/usePrice'
|
||||||
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
import { normalizeDecimalInput } from '@/utils/persianDigits'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: CountPInventory
|
item: CountPInventory
|
||||||
@@ -121,7 +121,9 @@ function selectCountInput(e: FocusEvent) {
|
|||||||
|
|
||||||
function onCountInput(raw: string | number) {
|
function onCountInput(raw: string | number) {
|
||||||
countError.value = ''
|
countError.value = ''
|
||||||
const normalized = persianToEnglishDigits(String(raw ?? '').trim())
|
const normalized = normalizeDecimalInput(String(raw ?? '').trim(), {
|
||||||
|
commaAsThousand: isCountBased.value,
|
||||||
|
})
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
props.item.count = 0 as any
|
props.item.count = 0 as any
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ usePhysicalReceiveList()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Mobile shell: fill viewport and scroll inside the list view */
|
||||||
.physical-receive-list-root {
|
.physical-receive-list-root {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -34,19 +35,19 @@ usePhysicalReceiveList()
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: let list grow with content; page scroll handles overflow */
|
/* Desktop: fill available height; page scrolls if content exceeds */
|
||||||
.physical-receive-list-root--desktop {
|
.physical-receive-list-root--desktop {
|
||||||
flex: none;
|
display: flex;
|
||||||
display: block;
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.physical-receive-list-root--desktop > * {
|
.physical-receive-list-root--desktop > * {
|
||||||
flex: none;
|
flex: 1 1 auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
.dpr {
|
.dpr {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
background: #f6f7f9;
|
background: #f6f7f9;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
max-width: 1180px;
|
max-width: 1180px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 28px 36px 40px;
|
padding: 28px 36px 40px;
|
||||||
|
min-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,17 +51,38 @@ const { isDesktop } = usePhysicalReceiveShell()
|
|||||||
}
|
}
|
||||||
|
|
||||||
.physical-receive-page-root--desktop {
|
.physical-receive-page-root--desktop {
|
||||||
display: block;
|
height: 100%;
|
||||||
height: auto;
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.physical-receive-page-root--desktop :deep(.physical-receive-list-root),
|
.physical-receive-page-root--desktop > .dpr {
|
||||||
.physical-receive-page-root--desktop :deep(.dpr-list) {
|
flex: 1 1 auto;
|
||||||
height: auto;
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.physical-receive-page-root--desktop :deep(.dpr__container) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.physical-receive-page-root--desktop :deep(.physical-receive-list-root--desktop) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: visible;
|
}
|
||||||
|
|
||||||
|
.physical-receive-page-root--desktop :deep(.physical-receive-list-root--desktop > .dpr-list) {
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
|
|
||||||
<div class="dptr-card__meta">
|
<div class="dptr-card__meta">
|
||||||
<div class="dptr-card__row">
|
<div class="dptr-card__row">
|
||||||
<span class="dptr-card__label">جهت قیمت:</span>
|
<span class="dptr-card__label">محصول:</span>
|
||||||
<span class="dptr-card__value">{{ returnPriceCondition(item.price_condition) }}</span>
|
<span class="dptr-card__value">{{ productTitle }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dptr-card__row">
|
<div class="dptr-card__row">
|
||||||
<span class="dptr-card__label">نوع هشدار:</span>
|
<span class="dptr-card__label">جهت قیمت:</span>
|
||||||
<span class="dptr-card__value">{{ returnAction(item.action) }}</span>
|
<span class="dptr-card__value">{{ returnPriceCondition(item.price_condition) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,8 +76,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 mt-2">
|
<div class="flex flex-col gap-2 mt-2">
|
||||||
|
<BasicInfoItem is-line title="محصول" :value="productTitle" />
|
||||||
<BasicInfoItem is-line title="جهت قیمت" :value="returnPriceCondition(item.price_condition)" />
|
<BasicInfoItem is-line title="جهت قیمت" :value="returnPriceCondition(item.price_condition)" />
|
||||||
<BasicInfoItem is-line title="نوع هشدار" :value="returnAction(item.action)" />
|
|
||||||
</div>
|
</div>
|
||||||
<a-divider class="!my-3" />
|
<a-divider class="!my-3" />
|
||||||
<div class="flex justify-between mt-1">
|
<div class="flex justify-between mt-1">
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { usePrice } from '../../../../utils/usePrice'
|
import { usePrice } from '../../../../utils/usePrice'
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { toJalali } from '../../../../utils/useDateTime'
|
import { toJalali } from '../../../../utils/useDateTime'
|
||||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
import type { PriceTrigger } from '@/models/PriceTrigger'
|
import type { PriceTrigger } from '@/models/PriceTrigger'
|
||||||
@@ -132,14 +132,16 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emits = defineEmits(['edit', 'refresh'])
|
const emits = defineEmits(['edit', 'refresh'])
|
||||||
|
|
||||||
|
const productTitle = computed(() => props.item.product?.title || 'طلا')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
isActive.value = props.item.is_active
|
isActive.value = props.item.is_active
|
||||||
})
|
})
|
||||||
|
|
||||||
function changeActive(val: boolean) {
|
async function changeActive(val: boolean) {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
updatePriceTrigger(props?.item.id, { is_active: val })
|
await updatePriceTrigger(props?.item.id, { is_active: val })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
isActive.value = !isActive.value
|
isActive.value = !isActive.value
|
||||||
@@ -171,15 +173,4 @@ async function deleteItem() {
|
|||||||
deleteLoading.value = false
|
deleteLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function returnAction(val: string) {
|
|
||||||
switch (val) {
|
|
||||||
case 'sms':
|
|
||||||
return 'پیامک'
|
|
||||||
case 'email':
|
|
||||||
return 'ایمیل'
|
|
||||||
case 'notify':
|
|
||||||
return 'نوتیفیکیشن'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,6 +7,16 @@
|
|||||||
@finish="ok()"
|
@finish="ok()"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4 w-full">
|
<div class="flex flex-col gap-4 w-full">
|
||||||
|
<a-form-item :rules="[required()]" label="محصول" name="product_id">
|
||||||
|
<BasicSelect
|
||||||
|
v-model:value="form.product_id"
|
||||||
|
:items="products"
|
||||||
|
key-name="id"
|
||||||
|
placeholder="آبشده، سکه، نقره و ..."
|
||||||
|
size="large"
|
||||||
|
value-name="title"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item :rules="[required()]" label="جهت قیمت" name="price_condition">
|
<a-form-item :rules="[required()]" label="جهت قیمت" name="price_condition">
|
||||||
<BasicSelect
|
<BasicSelect
|
||||||
v-model:value="form.price_condition"
|
v-model:value="form.price_condition"
|
||||||
@@ -27,12 +37,7 @@
|
|||||||
type="price"
|
type="price"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item :rules="[required()]" label="نوع هشدار را انتخاب کنید" name="action">
|
<p class="m-0 text-sm text-gray-500">پس از فعال شدن هشدار، پیامک به شماره موبایل شما ارسال میشود.</p>
|
||||||
<a-radio-group v-model:value="form.action">
|
|
||||||
<a-radio :style="radioStyle" value="sms">ارسال پیامک</a-radio>
|
|
||||||
<a-radio :style="radioStyle" value="email">ارسال ایمیل</a-radio>
|
|
||||||
</a-radio-group>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<BasicButton class="w-full" html-type="submit" type="primary">
|
<BasicButton class="w-full" html-type="submit" type="primary">
|
||||||
@@ -56,8 +61,8 @@ const {
|
|||||||
formRef,
|
formRef,
|
||||||
editId,
|
editId,
|
||||||
loading,
|
loading,
|
||||||
radioStyle,
|
|
||||||
typeItems,
|
typeItems,
|
||||||
|
products,
|
||||||
ok,
|
ok,
|
||||||
} = usePriceTriggerPageContext()
|
} = usePriceTriggerPageContext()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,12 +2,25 @@ import { inject, onMounted, provide, reactive, ref, type InjectionKey, type Ref
|
|||||||
import { cloneDeep } from 'lodash'
|
import { cloneDeep } from 'lodash'
|
||||||
import { type FormInstance, message } from 'ant-design-vue'
|
import { type FormInstance, message } from 'ant-design-vue'
|
||||||
import { createPriceTrigger, getPriceTriggers, updatePriceTrigger } from '@/api/triggerPrice'
|
import { createPriceTrigger, getPriceTriggers, updatePriceTrigger } from '@/api/triggerPrice'
|
||||||
|
import { getProducts } from '@/api/products'
|
||||||
import type { SelectItem } from '@/models/SelectItem'
|
import type { SelectItem } from '@/models/SelectItem'
|
||||||
import type { PriceTrigger } from '@/models/PriceTrigger'
|
import type { PriceTrigger } from '@/models/PriceTrigger'
|
||||||
|
import type { Product } from '@/models/products'
|
||||||
import { loading } from '@/utils'
|
import { loading } from '@/utils'
|
||||||
|
|
||||||
|
function emptyForm(): PriceTrigger {
|
||||||
|
return {
|
||||||
|
product_id: undefined,
|
||||||
|
price_condition: '',
|
||||||
|
is_active: true,
|
||||||
|
action: 'sms',
|
||||||
|
target_price: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface PriceTriggerPageContext {
|
export interface PriceTriggerPageContext {
|
||||||
items: Ref<PriceTrigger[]>
|
items: Ref<PriceTrigger[]>
|
||||||
|
products: Ref<Product[]>
|
||||||
addTriggerVisible: Ref<boolean>
|
addTriggerVisible: Ref<boolean>
|
||||||
editId: Ref<number | undefined>
|
editId: Ref<number | undefined>
|
||||||
form: Ref<PriceTrigger>
|
form: Ref<PriceTrigger>
|
||||||
@@ -27,6 +40,7 @@ export const PRICE_TRIGGER_PAGE_CONTEXT_KEY: InjectionKey<PriceTriggerPageContex
|
|||||||
|
|
||||||
export function usePriceTriggerPage() {
|
export function usePriceTriggerPage() {
|
||||||
const items = ref<PriceTrigger[]>([])
|
const items = ref<PriceTrigger[]>([])
|
||||||
|
const products = ref<Product[]>([])
|
||||||
const addTriggerVisible = ref(false)
|
const addTriggerVisible = ref(false)
|
||||||
const editId = ref<number | undefined>(undefined)
|
const editId = ref<number | undefined>(undefined)
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
@@ -37,12 +51,7 @@ export function usePriceTriggerPage() {
|
|||||||
lineHeight: '30px',
|
lineHeight: '30px',
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = ref<PriceTrigger>({
|
const form = ref<PriceTrigger>(emptyForm())
|
||||||
price_condition: '',
|
|
||||||
is_active: true,
|
|
||||||
action: '',
|
|
||||||
target_price: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeItems: SelectItem[] = [
|
const typeItems: SelectItem[] = [
|
||||||
{ value: 'بزرگتر', key: '>' },
|
{ value: 'بزرگتر', key: '>' },
|
||||||
@@ -51,12 +60,7 @@ export function usePriceTriggerPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
function resetFields() {
|
function resetFields() {
|
||||||
form.value = {
|
form.value = emptyForm()
|
||||||
price_condition: '',
|
|
||||||
is_active: true,
|
|
||||||
action: '',
|
|
||||||
target_price: '',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
@@ -66,7 +70,10 @@ export function usePriceTriggerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(val: PriceTrigger) {
|
function openEdit(val: PriceTrigger) {
|
||||||
form.value = cloneDeep(val)
|
form.value = cloneDeep({
|
||||||
|
...val,
|
||||||
|
product_id: val.product_id ?? val.product?.id,
|
||||||
|
})
|
||||||
editId.value = form.value.id
|
editId.value = form.value.id
|
||||||
addTriggerVisible.value = true
|
addTriggerVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -82,10 +89,19 @@ export function usePriceTriggerPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadProducts() {
|
||||||
|
try {
|
||||||
|
products.value = ((await getProducts()) as Product[]) ?? []
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ok() {
|
async function ok() {
|
||||||
await formRef.value?.validateFields().then(async () => {
|
await formRef.value?.validateFields().then(async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
form.value.action = 'sms'
|
||||||
if (editId.value) {
|
if (editId.value) {
|
||||||
await updatePriceTrigger(editId.value, form.value)
|
await updatePriceTrigger(editId.value, form.value)
|
||||||
} else {
|
} else {
|
||||||
@@ -104,10 +120,12 @@ export function usePriceTriggerPage() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initItems()
|
initItems()
|
||||||
|
loadProducts()
|
||||||
})
|
})
|
||||||
|
|
||||||
const context: PriceTriggerPageContext = {
|
const context: PriceTriggerPageContext = {
|
||||||
items,
|
items,
|
||||||
|
products,
|
||||||
addTriggerVisible,
|
addTriggerVisible,
|
||||||
editId,
|
editId,
|
||||||
form,
|
form,
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-35rem overflow-y-auto">
|
<div class="h-[40rem] overflow-y-auto">
|
||||||
<div class=" !overflow-y-scroll bg-white py-3 text-primary font-bold flex justify-evenly">
|
<div class="bg-white px-3 pt-3 pb-1">
|
||||||
<!-- <div @click="isBuy = false" class="w-full text-center" :class="!isBuy?'text-primary':'text-gray-400'">خرید طلا</div>
|
<div class="flex w-full p-1 items-center border border-gray-200 rounded-full bg-white gap-1">
|
||||||
<div @click="isBuy = true" class="w-full text-center" :class="isBuy?'text-primary':'text-gray-400'">فروش طلا</div>-->
|
<div
|
||||||
<div class="flex justify-evenly w-full px-2 h-11 items-center bg-gray-50 relative rounded ">
|
class="w-full h-10 flex items-center justify-center font-bold rounded-full transition-all"
|
||||||
<div :class="isBuy?'text-secondary':'text-gray-400'" class="w-full text-center z-2"
|
:class="isBuy ? 'bg-green-600 text-white shadow-sm' : 'text-green-600'"
|
||||||
@click.stop="setIsBuy(true) "
|
@click.stop="setIsBuy(true)"
|
||||||
:style="!props?.ProductItem?.open_buy ? 'pointer-events: none; opacity: 0.5;' : ''">
|
:style="!props?.ProductItem?.open_buy ? 'pointer-events: none; opacity: 0.35;' : ''"
|
||||||
خرید {{ props?.ProductItem?.title }}
|
|
||||||
</div>
|
|
||||||
<div :class="!isBuy?'text-secondary':'text-gray-400'" class="w-full text-center z-2"
|
|
||||||
@click.stop="setIsBuy(false) "
|
|
||||||
:style="!props?.ProductItem?.open_sell ? 'pointer-events: none; opacity: 0.5;' : ''"
|
|
||||||
>
|
>
|
||||||
فروش {{ props?.ProductItem?.title }}
|
خرید
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-full h-10 flex items-center justify-center font-bold rounded-full transition-all"
|
||||||
|
:class="!isBuy ? 'bg-red-600 text-white shadow-sm' : 'text-red-600'"
|
||||||
|
@click.stop="setIsBuy(false)"
|
||||||
|
:style="!props?.ProductItem?.open_sell ? 'pointer-events: none; opacity: 0.35;' : ''"
|
||||||
|
>
|
||||||
|
فروش
|
||||||
</div>
|
</div>
|
||||||
<!-- <div ref="bganim" class="absolute bg-white h-8 w-1/2 rounded-lg " :style="`transform: translateX(${isbuy? '0':'-100%'}); left: ${isbuy? '0':'100%'}`"></div>-->
|
|
||||||
<div ref="bganim" :style="useStyle()"
|
|
||||||
class="absolute bg-white h-8 w-1/2 rounded-lg animate right"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userStore" class=" mt-5 pb-2 mb-19vh">
|
<div v-if="userStore" class=" mt-5 pb-8 mb-16vh">
|
||||||
<!-- <a-spin :spinning="loading">-->
|
<!-- <a-spin :spinning="loading">-->
|
||||||
<div
|
<div
|
||||||
v-if="!loading"
|
v-if="!loading"
|
||||||
class="w-full relative rounded-lg flex flex-col justify-center background-color-transition"
|
class="w-full relative rounded-2xl flex flex-col justify-center background-color-transition"
|
||||||
:class="[
|
:class="[
|
||||||
!isBuy ? 'bg-red-100' : 'bg-green-100',
|
!isBuy ? 'bg-red-100' : 'bg-green-100',
|
||||||
selectedMeltedPaymentProduct ? 'min-h-[120px] py-3' : 'h-[88px]',
|
selectedMeltedPaymentProduct ? 'min-h-[120px] py-3' : 'h-[88px]',
|
||||||
@@ -32,7 +32,16 @@
|
|||||||
>
|
>
|
||||||
<img :src="img1" alt="" class="h-full min-h-[88px] absolute inset-0 w-full object-cover">
|
<img :src="img1" alt="" class="h-full min-h-[88px] absolute inset-0 w-full object-cover">
|
||||||
<div class="w-full items-center flex flex-col relative z-1">
|
<div class="w-full items-center flex flex-col relative z-1">
|
||||||
<div class="text-gray-700 font-bold">{{ !isBuy ? 'قیمت فروش' : 'قیمت خرید' }}</div>
|
<div class="flex items-center justify-center gap-2 flex-wrap px-3">
|
||||||
|
<span class="text-gray-700 font-bold">{{ !isBuy ? 'قیمت فروش' : 'قیمت خرید' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="props?.ProductItem?.title"
|
||||||
|
class="text-sm font-extrabold px-3 py-1 rounded-full max-w-[14rem] truncate shadow-sm"
|
||||||
|
:class="!isBuy ? 'bg-red-200/90 text-red-900' : 'bg-green-200/90 text-green-900'"
|
||||||
|
>
|
||||||
|
{{ props.ProductItem.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div v-if="price" class="flex gap-1 mt-3 text-xs items-center">
|
<div v-if="price" class="flex gap-1 mt-3 text-xs items-center">
|
||||||
<div :class="!isBuy? 'text-red-700' : 'text-green-700'" class="font-bold text-2xl color-transition">
|
<div :class="!isBuy? 'text-red-700' : 'text-green-700'" class="font-bold text-2xl color-transition">
|
||||||
{{ isBuy ? usePriceWithoutType(buyTotalPrice) : usePriceWithoutType(sellTotalPrice) }}
|
{{ isBuy ? usePriceWithoutType(buyTotalPrice) : usePriceWithoutType(sellTotalPrice) }}
|
||||||
@@ -56,7 +65,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- </a-spin>-->
|
<!-- </a-spin>-->
|
||||||
<a-form ref="refInput" :model="form" class="!mt-5" name="basic" @finish="okConfirm()">
|
<a-form ref="refInput" :model="form" class="!mt-5" name="basic" @finish="okConfirm()">
|
||||||
<div class="border-gray-200 border-1 rounded-xl p-5 relative">
|
<div class="border-gray-200 border-1 rounded-2xl p-5 relative">
|
||||||
<bg_main />
|
<bg_main />
|
||||||
<div class="z-100 relative">
|
<div class="z-100 relative">
|
||||||
<a-form-item v-if="showDealMethodSelect" name="method" :rules="[required()]">
|
<a-form-item v-if="showDealMethodSelect" name="method" :rules="[required()]">
|
||||||
@@ -281,7 +290,7 @@ import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
|||||||
import { useUserStore } from '@/store/userStore'
|
import { useUserStore } from '@/store/userStore'
|
||||||
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
||||||
import { justNumber2, justNumber3, required } from '@/utils/inputRules'
|
import { justNumber2, justNumber3, required } from '@/utils/inputRules'
|
||||||
import { persianToEnglishDigits } from '@/utils/persianDigits'
|
import { parseDecimalInput } from '@/utils/persianDigits'
|
||||||
import GoldSkeleton from '@/views/base/gold/components/GoldSkeleton.vue'
|
import GoldSkeleton from '@/views/base/gold/components/GoldSkeleton.vue'
|
||||||
import GoldLoading from '@/components/GoldLoading.vue'
|
import GoldLoading from '@/components/GoldLoading.vue'
|
||||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
@@ -710,9 +719,6 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// تابع تبدیل اعداد فارسی به انگلیسی
|
|
||||||
const persianToEnglish = persianToEnglishDigits
|
|
||||||
|
|
||||||
watch(() => form.value.goldValue, (newVal) => {
|
watch(() => form.value.goldValue, (newVal) => {
|
||||||
if (newVal !== undefined && newVal !== null && newVal !== '') {
|
if (newVal !== undefined && newVal !== null && newVal !== '') {
|
||||||
calculateCash(newVal)
|
calculateCash(newVal)
|
||||||
@@ -759,10 +765,7 @@ function handleGoldValueChange(val: string | number | undefined) {
|
|||||||
form.value.goldValue = undefined
|
form.value.goldValue = undefined
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// تبدیل اعداد فارسی به انگلیسی
|
const numericValue = parseDecimalInput(val, { commaAsThousand: false })
|
||||||
const valStr = val.toString()
|
|
||||||
const convertedValue = persianToEnglish(valStr)
|
|
||||||
const numericValue = parseFloat(convertedValue)
|
|
||||||
form.value.goldValue = isNaN(numericValue) ? undefined : numericValue
|
form.value.goldValue = isNaN(numericValue) ? undefined : numericValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,52 @@ async function loadCategoryTabs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function productFileId(product: Product): number {
|
||||||
|
return Number(product.file_id ?? product.file?.id ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function productHasDisplayImage(product: Product): boolean {
|
||||||
|
const file = product.file
|
||||||
|
if (file && (file.full_src || file.url || file.src)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !!(product.img && /^https?:\/\//i.test(product.img))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** استریم گاهی file خالی با همان file_id میفرستد — عکس خوب HTTP را پاک نکن. */
|
||||||
|
function mergeLiveProductList(incoming: Product[], current?: Product[]): Product[] {
|
||||||
|
if (!current?.length) return incoming
|
||||||
|
|
||||||
|
const currentById = new Map(current.map((item) => [item.id, item]))
|
||||||
|
return incoming.map((item) => {
|
||||||
|
const prev = currentById.get(item.id)
|
||||||
|
if (!prev) return item
|
||||||
|
|
||||||
|
const prevId = productFileId(prev)
|
||||||
|
const nextId = productFileId(item)
|
||||||
|
|
||||||
|
if (!productHasDisplayImage(item) && productHasDisplayImage(prev)) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
file: prev.file,
|
||||||
|
file_id: prev.file_id ?? prev.file?.id ?? item.file_id,
|
||||||
|
img: prev.img,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevId > nextId) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
file: prev.file,
|
||||||
|
file_id: prev.file_id,
|
||||||
|
img: prev.img,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function applyProductsList(products: Product[]) {
|
function applyProductsList(products: Product[]) {
|
||||||
if (products.length) {
|
if (products.length) {
|
||||||
productsCloseTime.value = products[0].auto_close_at
|
productsCloseTime.value = products[0].auto_close_at
|
||||||
@@ -141,8 +187,12 @@ export function applyProductsListFromSocket(products: Product[]) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
getProductsCatalogRef().value = products
|
const merged = mergeLiveProductList(
|
||||||
applyProductsList(products)
|
products,
|
||||||
|
getProductsCatalogRef().value.length ? getProductsCatalogRef().value : productsFirstItems.value,
|
||||||
|
)
|
||||||
|
getProductsCatalogRef().value = merged
|
||||||
|
applyProductsList(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopProductListPolling() {
|
function stopProductListPolling() {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div class="recovery-sites-list" :class="`recovery-sites-list--${variant}`">
|
||||||
|
<p v-if="loading" class="recovery-sites-list__empty">
|
||||||
|
در حال بارگذاری سایتها...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else-if="isEmpty" class="recovery-sites-list__empty">
|
||||||
|
سایتی برای نمایش ثبت نشده است.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="site in sites"
|
||||||
|
:key="site.id"
|
||||||
|
type="button"
|
||||||
|
class="recovery-sites-list__item"
|
||||||
|
@click="openSite(site.url)"
|
||||||
|
>
|
||||||
|
<span class="recovery-sites-list__icon">
|
||||||
|
<BasicIcon icon="vuesax-linear:global" :size="variant === 'desktop' ? 20 : 18" />
|
||||||
|
</span>
|
||||||
|
<span class="recovery-sites-list__content">
|
||||||
|
<span class="recovery-sites-list__name">{{ site.name }}</span>
|
||||||
|
<span class="recovery-sites-list__url" dir="ltr">{{ site.url }}</span>
|
||||||
|
</span>
|
||||||
|
<BasicIcon
|
||||||
|
class="recovery-sites-list__chevron"
|
||||||
|
icon="vuesax-linear:export"
|
||||||
|
:size="variant === 'desktop' ? 18 : 16"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import { useRecoverySitesPageContext } from '../composables/useRecoverySitesPage'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
variant?: 'mobile' | 'desktop'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
variant: 'mobile',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { sites, loading, isEmpty, openSite } = useRecoverySitesPageContext()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.recovery-sites-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-sites-list__empty {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--app-text-secondary, #6b7280);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-sites-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(var(--app-gold-rgb, 212, 175, 55), 0.16);
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
text-align: right;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-sites-list__item:hover {
|
||||||
|
border-color: rgba(var(--app-gold-rgb, 212, 175, 55), 0.35);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-sites-list__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(var(--app-gold-rgb, 212, 175, 55), 0.12);
|
||||||
|
color: var(--app-primary, #b8860b);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-sites-list__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-sites-list__name {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-text-primary, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-sites-list__url {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--app-text-secondary, #6b7280);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-sites-list__chevron {
|
||||||
|
color: var(--app-text-secondary, #6b7280);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-sites-list--desktop .recovery-sites-list__item {
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
onMounted,
|
||||||
|
provide,
|
||||||
|
ref,
|
||||||
|
type ComputedRef,
|
||||||
|
type InjectionKey,
|
||||||
|
type Ref,
|
||||||
|
} from 'vue'
|
||||||
|
import {
|
||||||
|
fetchTrackingSettings,
|
||||||
|
resolveTrackingSystems,
|
||||||
|
type TrackingSystemSite,
|
||||||
|
} from '@/utils/trackingSystemsSettings'
|
||||||
|
|
||||||
|
export type ReygiriSite = TrackingSystemSite
|
||||||
|
|
||||||
|
export interface RecoverySitesPageContext {
|
||||||
|
sites: Ref<ReygiriSite[]>
|
||||||
|
loading: Ref<boolean>
|
||||||
|
isEmpty: ComputedRef<boolean>
|
||||||
|
openSite: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECOVERY_SITES_PAGE_CONTEXT_KEY: InjectionKey<RecoverySitesPageContext> =
|
||||||
|
Symbol('recoverySitesPageContext')
|
||||||
|
|
||||||
|
function normalizeExternalUrl(url: string) {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return `https://${trimmed.replace(/^\/\//, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecoverySitesPage() {
|
||||||
|
const sites = ref<ReygiriSite[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const isEmpty = computed(() => !loading.value && sites.value.length === 0)
|
||||||
|
|
||||||
|
async function loadSites() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const settings = await fetchTrackingSettings(true)
|
||||||
|
sites.value = resolveTrackingSystems(settings)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
sites.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadSites()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openSite(url: string) {
|
||||||
|
const normalized = normalizeExternalUrl(url)
|
||||||
|
if (!normalized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(normalized, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: RecoverySitesPageContext = {
|
||||||
|
sites,
|
||||||
|
loading,
|
||||||
|
isEmpty,
|
||||||
|
openSite,
|
||||||
|
}
|
||||||
|
|
||||||
|
provide(RECOVERY_SITES_PAGE_CONTEXT_KEY, context)
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecoverySitesPageContext() {
|
||||||
|
const context = inject(RECOVERY_SITES_PAGE_CONTEXT_KEY)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useRecoverySitesPageContext must be used within a page that calls useRecoverySitesPage()',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="drecovery">
|
||||||
|
<div class="drecovery__container">
|
||||||
|
<header class="drecovery-topbar">
|
||||||
|
<button type="button" class="drecovery-topbar__back" @click="router.back()">
|
||||||
|
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
|
||||||
|
<span>بازگشت</span>
|
||||||
|
</button>
|
||||||
|
<div class="drecovery-topbar__meta">
|
||||||
|
<p class="drecovery-topbar__eyebrow">خدمات</p>
|
||||||
|
<h1 class="drecovery-topbar__title">سایتهای ریگیری</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="drecovery-surface drecovery-main">
|
||||||
|
<div class="drecovery-main__intro">
|
||||||
|
<div class="drecovery-main__logo">
|
||||||
|
<BasicIcon icon="vuesax-linear:global" size="28" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="drecovery-main__heading">سایتهای ریگیری</h2>
|
||||||
|
<p class="drecovery-main__tagline">دسترسی به سایتهای مرتبط با ریگیری</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecoverySitesList variant="desktop" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
import RecoverySitesList from '../components/RecoverySitesList.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.drecovery {
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1.5rem 1.75rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery__container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery-topbar__back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-text-secondary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery-topbar__eyebrow {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
color: var(--app-text-secondary, #6b7280);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery-topbar__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery-surface {
|
||||||
|
border: 1px solid rgba(var(--app-gold-rgb, 212, 175, 55), 0.18);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--app-surface, #fff);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery-main__intro {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery-main__logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(var(--app-gold-rgb, 212, 175, 55), 0.12);
|
||||||
|
color: var(--app-primary, #b8860b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery-main__heading {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drecovery-main__tagline {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--app-text-secondary, #6b7280);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div class="recovery-sites-page-root">
|
||||||
|
<DesktopRecoverySitesView v-show="isDesktop" />
|
||||||
|
<MobileRecoverySitesView v-show="!isDesktop" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||||
|
import { useRecoverySitesPage } from './composables/useRecoverySitesPage'
|
||||||
|
import DesktopRecoverySitesView from './desktop/DesktopRecoverySitesView.vue'
|
||||||
|
import MobileRecoverySitesView from './mobile/MobileRecoverySitesView.vue'
|
||||||
|
|
||||||
|
const isDesktop = useDesktopBreakpoint()
|
||||||
|
|
||||||
|
useRecoverySitesPage()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.recovery-sites-page-root {
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
.recovery-sites-page-root.main-with-bottom-chrome-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mobile-recovery-sites mobile-info-page">
|
||||||
|
<div class="mobile-info-page__bg" aria-hidden="true">
|
||||||
|
<img :src="img" alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MobilePageHeader title="سایتهای ریگیری" show-back />
|
||||||
|
|
||||||
|
<div class="mobile-recovery-sites__main mobile-info-page__main">
|
||||||
|
<div class="mobile-recovery-sites__card">
|
||||||
|
<RecoverySitesList variant="mobile" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import img from '@/assets/img/user_ly1.svg'
|
||||||
|
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
|
||||||
|
import RecoverySitesList from '../components/RecoverySitesList.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-recovery-sites__main {
|
||||||
|
padding-bottom: calc(var(--app-bottom-chrome-height, 108px) + 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-recovery-sites__card {
|
||||||
|
border: 1px solid rgba(var(--app-gold-rgb, 212, 175, 55), 0.16);
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
background: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -106,6 +106,10 @@
|
|||||||
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">مهلت ارسال فیش:</div>
|
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">مهلت ارسال فیش:</div>
|
||||||
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ item.eaj }}</div>
|
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ item.eaj }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="item.note" :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
|
||||||
|
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">توضیحات سند:</div>
|
||||||
|
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ item.note }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
let activeFetchId = 0
|
let activeFetchId = 0
|
||||||
|
|
||||||
export const transactionsBoursecuMode = ref(true)
|
export const transactionsBoursecuMode = ref(true)
|
||||||
export const transactionPagination = ref<TableEngineInput>({ filters: [], limit: 7, page: 0 })
|
export const transactionPagination = ref<TableEngineInput>({ filters: [], limit: 10, page: 0 })
|
||||||
export const transactionItems = ref<Transaction[]>([])
|
export const transactionItems = ref<Transaction[]>([])
|
||||||
export const physicalReceiveItems = ref<Physicalreceive[]>([])
|
export const physicalReceiveItems = ref<Physicalreceive[]>([])
|
||||||
export const transactionTableOutput = ref<TableEngineOutput<Transaction | Physicalreceive> | null>(
|
export const transactionTableOutput = ref<TableEngineOutput<Transaction | Physicalreceive> | null>(
|
||||||
@@ -151,7 +151,7 @@ export function resetTransactionsPageData() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
loadingMore.value = false
|
loadingMore.value = false
|
||||||
transactionsBoursecuMode.value = true
|
transactionsBoursecuMode.value = true
|
||||||
transactionPagination.value = { filters: [], limit: 7, page: 0 }
|
transactionPagination.value = { filters: [], limit: 10, page: 0 }
|
||||||
transactionItems.value = []
|
transactionItems.value = []
|
||||||
physicalReceiveItems.value = []
|
physicalReceiveItems.value = []
|
||||||
transactionTableOutput.value = null
|
transactionTableOutput.value = null
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export function buildBoursecuTransactionPagination(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
page: 0,
|
page: 0,
|
||||||
limit: 7,
|
limit: 10,
|
||||||
filters,
|
filters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tv-board">
|
||||||
|
<header class="tv-board__header">
|
||||||
|
<h1 class="tv-board__title">{{ shopName }}</h1>
|
||||||
|
<time class="tv-board__clock" dir="ltr">{{ clockText }}</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="statusBanner" class="tv-board__banner">{{ statusBanner }}</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="tv-board__state">
|
||||||
|
<a-spin />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!rows.length" class="tv-board__state">
|
||||||
|
<BasicIcon icon="vuesax-linear:monitor" size="42" />
|
||||||
|
<span>محصولی برای نمایش روی تابلو انتخاب نشده است</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="tv-board__table">
|
||||||
|
<div class="tv-board__row tv-board__row--head">
|
||||||
|
<span>محصول</span>
|
||||||
|
<span>خرید</span>
|
||||||
|
<span>فروش</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="row in rows" :key="row.id" class="tv-board__row">
|
||||||
|
<span class="tv-board__name">{{ row.title }}</span>
|
||||||
|
<span class="tv-board__buy" dir="ltr">{{ row.buyText }}</span>
|
||||||
|
<span class="tv-board__sell" dir="ltr">{{ row.sellText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||||
|
import { getTvProducts } from '@/api/products'
|
||||||
|
import type { Product } from '@/models/products'
|
||||||
|
import { getConfigApp } from '@/utils/config'
|
||||||
|
import { getProductBuyPrice, getProductSellPrice, isProductPriceInactive } from '@/utils/productPrice'
|
||||||
|
import { lastPrice, getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
|
||||||
|
import { getPriceDisplayMessage, getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
|
||||||
|
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||||
|
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
|
||||||
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
|
|
||||||
|
const REFRESH_MS = 15000
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const products = ref<Product[]>([])
|
||||||
|
const now = ref(Date.now())
|
||||||
|
|
||||||
|
let clockTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
let listTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
const shopName = computed(
|
||||||
|
() => String(getConfigApp('name_fa') || getConfigApp('name') || 'تابلو قیمت'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const clockText = computed(() =>
|
||||||
|
new Date(now.value).toLocaleTimeString('fa-IR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusBanner = computed(() => {
|
||||||
|
void lastPrice.value
|
||||||
|
const state = getPriceDisplayState()
|
||||||
|
return state === 'open' ? '' : getPriceDisplayMessage(state)
|
||||||
|
})
|
||||||
|
|
||||||
|
function isShowOnTv(product: Product): boolean {
|
||||||
|
const value = product.show_on_tv as unknown
|
||||||
|
return value === true || value === 1 || value === '1' || value === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
void lastPrice.value
|
||||||
|
// API فقط محصولات تابلو را میدهد؛ اگر فیلد show_on_tv نبود، همه را نشان بده
|
||||||
|
const list = products.value
|
||||||
|
const marked = list.filter(isShowOnTv)
|
||||||
|
const source = marked.length ? marked : list
|
||||||
|
return source.map((product) => {
|
||||||
|
if (isProductPriceInactive(product)) {
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
title: product.title,
|
||||||
|
buyText: NO_QUOTE_MESSAGE,
|
||||||
|
sellText: NO_QUOTE_MESSAGE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
title: product.title,
|
||||||
|
buyText: usePriceWithoutType(getProductBuyPrice(product)),
|
||||||
|
sellText: usePriceWithoutType(getProductSellPrice(product)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadProducts() {
|
||||||
|
try {
|
||||||
|
const list = await getTvProducts()
|
||||||
|
products.value = Array.isArray(list) ? list : []
|
||||||
|
} catch {
|
||||||
|
if (!products.value.length) {
|
||||||
|
products.value = []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key))
|
||||||
|
startLastPricePolling()
|
||||||
|
void getLastPrices()
|
||||||
|
void loadProducts()
|
||||||
|
clockTimer = setInterval(() => {
|
||||||
|
now.value = Date.now()
|
||||||
|
}, 1000)
|
||||||
|
listTimer = setInterval(() => {
|
||||||
|
void loadProducts()
|
||||||
|
}, REFRESH_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopLastPricePolling()
|
||||||
|
if (clockTimer) {
|
||||||
|
clearInterval(clockTimer)
|
||||||
|
}
|
||||||
|
if (listTimer) {
|
||||||
|
clearInterval(listTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tv-board {
|
||||||
|
@apply min-h-screen w-full flex flex-col gap-5 p-6 text-white bg-gray-950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__header {
|
||||||
|
@apply flex items-center justify-between gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__title {
|
||||||
|
@apply m-0 text-2xl font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__clock {
|
||||||
|
@apply text-xl font-bold text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__banner {
|
||||||
|
@apply m-0 rounded-xl bg-gray-800 px-4 py-3 text-center text-sm text-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__state {
|
||||||
|
@apply flex flex-1 flex-col items-center justify-center gap-3 text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__table {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__row {
|
||||||
|
@apply grid grid-cols-3 items-center gap-4 rounded-xl bg-gray-900 px-4 py-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__row--head {
|
||||||
|
@apply bg-gray-800 text-sm text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__name {
|
||||||
|
@apply text-lg font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__buy {
|
||||||
|
@apply text-xl font-bold text-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tv-board__sell {
|
||||||
|
@apply text-xl font-bold text-red-400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,6 +21,8 @@ import { getReferralShareText, getReferralShareTitle, shareReferralText } from '
|
|||||||
import { normalizeAuthFormFields } from '@/utils/persianDigits'
|
import { normalizeAuthFormFields } from '@/utils/persianDigits'
|
||||||
import { useSettingsStore } from '@/store/settingsStore'
|
import { useSettingsStore } from '@/store/settingsStore'
|
||||||
import { useSupportMessenger } from '@/composables/useSupportMessenger'
|
import { useSupportMessenger } from '@/composables/useSupportMessenger'
|
||||||
|
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
|
||||||
|
import { openGoldDayCalculator } from '@/composables/useGoldDayCalculator'
|
||||||
import { isUserSupportChatEnabled, unreadSupportCount } from '@/utils/chat'
|
import { isUserSupportChatEnabled, unreadSupportCount } from '@/utils/chat'
|
||||||
import { resetHomePageData } from '../../home/composables/useHomePageData'
|
import { resetHomePageData } from '../../home/composables/useHomePageData'
|
||||||
import { resetWalletPageData } from '../../wallet/composables/useWalletPageData'
|
import { resetWalletPageData } from '../../wallet/composables/useWalletPageData'
|
||||||
@@ -87,6 +89,7 @@ export function useUserPage() {
|
|||||||
const showLogoutConfirm = ref(false)
|
const showLogoutConfirm = ref(false)
|
||||||
const localAvatarUrl = ref<string | null>(null)
|
const localAvatarUrl = ref<string | null>(null)
|
||||||
const { openSupportMessenger } = useSupportMessenger()
|
const { openSupportMessenger } = useSupportMessenger()
|
||||||
|
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
|
||||||
|
|
||||||
const profileAvatarUrl = computed(
|
const profileAvatarUrl = computed(
|
||||||
() => localAvatarUrl.value || getUserAvatarUrl(userStore.userInfo),
|
() => localAvatarUrl.value || getUserAvatarUrl(userStore.userInfo),
|
||||||
@@ -124,6 +127,13 @@ export function useUserPage() {
|
|||||||
onClick: () => { editPasswordVisible.value = true },
|
onClick: () => { editPasswordVisible.value = true },
|
||||||
visible: true,
|
visible: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'goldDayCalculator',
|
||||||
|
label: 'قیمت روز طلا',
|
||||||
|
icon: 'vuesax-linear:chart-2',
|
||||||
|
onClick: () => openGoldDayCalculator(),
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'address',
|
id: 'address',
|
||||||
label: 'آدرس ها',
|
label: 'آدرس ها',
|
||||||
@@ -131,13 +141,6 @@ export function useUserPage() {
|
|||||||
onClick: () => router.push({ name: 'address' }),
|
onClick: () => router.push({ name: 'address' }),
|
||||||
visible: true,
|
visible: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'notify',
|
|
||||||
label: 'هشدار قیمت',
|
|
||||||
icon: 'vuesax-linear:bill',
|
|
||||||
onClick: () => router.push({ name: 'notifyTrigger' }),
|
|
||||||
visible: settingsStore.isSettingTruthy('has_page_price_trigger'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'piggy',
|
id: 'piggy',
|
||||||
label: 'قلکها',
|
label: 'قلکها',
|
||||||
@@ -170,6 +173,20 @@ export function useUserPage() {
|
|||||||
onClick: () => router.push({ name: 'physicalReceive' }),
|
onClick: () => router.push({ name: 'physicalReceive' }),
|
||||||
visible: true,
|
visible: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'giftCard',
|
||||||
|
label: 'ثبت کارت هدیه',
|
||||||
|
icon: 'vuesax-linear:gift',
|
||||||
|
onClick: () => router.push({ name: 'giftCard' }),
|
||||||
|
visible: settingsStore.isSettingTruthy('has_page_gift_card'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recoverySites',
|
||||||
|
label: 'سایتهای ریگیری',
|
||||||
|
icon: 'vuesax-linear:global',
|
||||||
|
onClick: () => router.push({ name: 'recoverySites' }),
|
||||||
|
visible: trackingSystemsEnabled.value,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'rules',
|
id: 'rules',
|
||||||
label: 'قوانین و مقررات',
|
label: 'قوانین و مقررات',
|
||||||
@@ -227,11 +244,13 @@ export function useUserPage() {
|
|||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
normalizeAuthFormFields(userFormState.value)
|
normalizeAuthFormFields(userFormState.value)
|
||||||
await updateMe(userFormState.value)
|
const res: any = await updateMe(userFormState.value)
|
||||||
await userStore.getMyInfo()
|
await userStore.getMyInfo()
|
||||||
localAvatarUrl.value = getUserAvatarUrl(userStore.userInfo)
|
localAvatarUrl.value = getUserAvatarUrl(userStore.userInfo)
|
||||||
editPassVisible.value = false
|
editPassVisible.value = false
|
||||||
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
|
message.success(
|
||||||
|
res?.message || 'اطلاعات شما با موفقیت به روز رسانی شد',
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -18,12 +18,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="deposit-guide__accounts">
|
<div class="deposit-guide__accounts">
|
||||||
<div class="deposit-guide__account-row">
|
<div v-if="hasDepositCardValue" class="deposit-guide__account-row">
|
||||||
<span class="deposit-guide__account-label">{{ depositCardLabel }}</span>
|
<span class="deposit-guide__account-label">{{ depositCardLabel }}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="deposit-guide__account-value"
|
class="deposit-guide__account-value"
|
||||||
:class="{ 'deposit-guide__account-value--empty': !hasDepositCardValue }"
|
|
||||||
:aria-label="`کپی ${depositCardLabel}`"
|
:aria-label="`کپی ${depositCardLabel}`"
|
||||||
@click="copyDepositCardNumber"
|
@click="copyDepositCardNumber"
|
||||||
>
|
>
|
||||||
@@ -33,17 +32,33 @@
|
|||||||
size="18"
|
size="18"
|
||||||
color="#15803d"
|
color="#15803d"
|
||||||
class="deposit-guide__copy-icon"
|
class="deposit-guide__copy-icon"
|
||||||
:class="{ 'deposit-guide__copy-icon--disabled': !hasDepositCardValue }"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="deposit-guide__account-row">
|
<div v-if="hasDepositAccountNumberValue" class="deposit-guide__account-row">
|
||||||
|
<span class="deposit-guide__account-label">{{ depositAccountNumberLabel }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="deposit-guide__account-value"
|
||||||
|
:aria-label="`کپی ${depositAccountNumberLabel}`"
|
||||||
|
@click="copyDepositAccountNumber"
|
||||||
|
>
|
||||||
|
<span class="deposit-guide__account-text" dir="ltr">{{ depositAccountNumber }}</span>
|
||||||
|
<BasicIcon
|
||||||
|
icon="vuesax-linear:copy"
|
||||||
|
size="18"
|
||||||
|
color="#15803d"
|
||||||
|
class="deposit-guide__copy-icon"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasDepositShebaValue" class="deposit-guide__account-row">
|
||||||
<span class="deposit-guide__account-label">{{ depositShebaLabel }}</span>
|
<span class="deposit-guide__account-label">{{ depositShebaLabel }}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="deposit-guide__account-value"
|
class="deposit-guide__account-value"
|
||||||
:class="{ 'deposit-guide__account-value--empty': !hasDepositShebaValue }"
|
|
||||||
:aria-label="`کپی ${depositShebaLabel}`"
|
:aria-label="`کپی ${depositShebaLabel}`"
|
||||||
@click="copyDepositShebaNumber"
|
@click="copyDepositShebaNumber"
|
||||||
>
|
>
|
||||||
@@ -53,7 +68,6 @@
|
|||||||
size="18"
|
size="18"
|
||||||
color="#15803d"
|
color="#15803d"
|
||||||
class="deposit-guide__copy-icon"
|
class="deposit-guide__copy-icon"
|
||||||
:class="{ 'deposit-guide__copy-icon--disabled': !hasDepositShebaValue }"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,12 +133,16 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
depositCardNumber,
|
depositCardNumber,
|
||||||
|
depositAccountNumber,
|
||||||
depositSheba,
|
depositSheba,
|
||||||
depositCardLabel,
|
depositCardLabel,
|
||||||
|
depositAccountNumberLabel,
|
||||||
depositShebaLabel,
|
depositShebaLabel,
|
||||||
hasDepositCardValue,
|
hasDepositCardValue,
|
||||||
|
hasDepositAccountNumberValue,
|
||||||
hasDepositShebaValue,
|
hasDepositShebaValue,
|
||||||
copyDepositCardNumber,
|
copyDepositCardNumber,
|
||||||
|
copyDepositAccountNumber,
|
||||||
copyDepositShebaNumber,
|
copyDepositShebaNumber,
|
||||||
hasMessengerLink,
|
hasMessengerLink,
|
||||||
openDepositSupportMessenger,
|
openDepositSupportMessenger,
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function useWalletPage() {
|
|||||||
async function addCard() {
|
async function addCard() {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await createCard(cardForm.value)
|
const res: any = await createCard(cardForm.value)
|
||||||
await getCards()
|
await getCards()
|
||||||
showAddCard.value = false
|
showAddCard.value = false
|
||||||
cardForm.value = {
|
cardForm.value = {
|
||||||
@@ -156,7 +156,9 @@ export function useWalletPage() {
|
|||||||
cart_number: '',
|
cart_number: '',
|
||||||
shaba_number: '',
|
shaba_number: '',
|
||||||
}
|
}
|
||||||
message.success('کارت وارد شده با موفقیت افزوده شد و پس از تایید نمایش داده خواهد شد.')
|
message.success(
|
||||||
|
res?.message || 'اطلاعات کارت بانکی شما ثبت شد و پس از تایید ادمین فعال سازی خواهد شد',
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user