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:
2026-09-02 08:26:52 -04:00
co-authored by Cursor
parent e9012e26fb
commit 9356a2ab5e
112 changed files with 6044 additions and 583 deletions
+14
View File
@@ -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/
+5
View File
@@ -14,6 +14,11 @@ dist-ssr
coverage coverage
*.local *.local
# Local env
.env
.env.*
!.env.example
/cypress/videos/ /cypress/videos/
/cypress/screenshots/ /cypress/screenshots/
+4
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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 {
+6 -3
View File
@@ -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}`)
}
+4
View File
@@ -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`)
} }
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+1
View File
@@ -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;
}
}
}
+1 -1
View File
@@ -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>
+17 -9
View File
@@ -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() : ''
+70
View File
@@ -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>
+303
View File
@@ -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>
+64
View File
@@ -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>
+227
View File
@@ -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>
+12
View File
@@ -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
}
+39 -2
View File
@@ -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,
}
}
+16 -3
View File
@@ -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(/^\/+/, '')}`
} }
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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
} }
+130
View File
@@ -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')
}
+12 -4
View File
@@ -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
View File
@@ -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()
+2
View File
@@ -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
+4
View File
@@ -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
} }
+3
View File
@@ -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
+1 -1
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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)
+21
View File
@@ -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
View File
@@ -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,
+3 -3
View File
@@ -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
View File
@@ -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')) &&
+19 -4
View File
@@ -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
View File
@@ -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)
} }
+37
View File
@@ -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 لوکال می‌خواند */
}
}),
)
}
+176
View File
@@ -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)
})
}
}
+57 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+14 -2
View File
@@ -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)
+6 -2
View File
@@ -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(
+6
View File
@@ -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
} }
+22 -9
View File
@@ -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')
} }
} }
+76
View File
@@ -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
View File
@@ -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')} ریال پرداخت`,
}
}
+80
View File
@@ -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
View File
@@ -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 {
+37
View File
@@ -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
}
+5 -5
View File
@@ -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'),
} }
+145
View File
@@ -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
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!stripped) {
return { kind: 'text', text: getDefaultAboutSoftwareText(projectName) }
}
return { kind: 'html', html: value }
}
return { kind: 'text', text: value }
}
+181
View File
@@ -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>
+15 -33
View File
@@ -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
View File
@@ -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,8 +35,29 @@ 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)
}) })
</script> </script>
@@ -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>
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -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;
} }
+27 -6
View File
@@ -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>
+10 -19
View File
@@ -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>
+33
View File
@@ -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,
} }
} }
+188
View File
@@ -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>
+28 -9
View File
@@ -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