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/
+5
View File
@@ -14,6 +14,11 @@ dist-ssr
coverage
*.local
# Local env
.env
.env.*
!.env.example
/cypress/videos/
/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']
DisabledDate: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/disabled-date.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']
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']
InstallButton: typeof import('./src/components/InstallButton.vue')['default']
ListAnim: typeof import('./src/components/ListAnim.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']
ProductImage: typeof import('./src/components/ProductImage.vue')['default']
ProfileAvatarUpload: typeof import('./src/components/ProfileAvatarUpload.vue')['default']
PwaInstallBanner: typeof import('./src/components/pwa/PwaInstallBanner.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']
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']
TradeFeeConfirmSummary: typeof import('./src/components/TradeFeeConfirmSummary.vue')['default']
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet']
VanDialog: typeof import('vant/es')['Dialog']
+103 -20
View File
@@ -2,6 +2,65 @@
<html lang="fa" dir="rtl">
<head>
<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 http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
@@ -343,30 +402,49 @@
return window.location.protocol === 'https:' ? 'https:' : 'http:'
}
function getVersionUrls() {
var protocol = getProtocol()
function resolveTenantDomain() {
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 [
'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'
'https://adm.montzar.ir',
'https://adm.montech.ir',
'https://adm.montest.ir'
]
}
function getVersionUrls() {
var domain = encodeURIComponent(resolveTenantDomain())
return getSuperAdminBases().map(function (base) {
return base + '/get_version?domain=' + domain
})
}
function getConfigUrls() {
var protocol = getProtocol()
return [
protocol + '//nerkh.gold/get_user',
protocol + '//tablo.talasevin.ir/get_user',
protocol + '//abshode-service.montgit.ir/server/get_user',
protocol + '//212.23.201.173:8381/get_user',
protocol + '//91.212.174.137:4040/server/get_user'
]
var domain = encodeURIComponent(resolveTenantDomain())
return getSuperAdminBases().map(function (base) {
return base + '/get_user?domain=' + domain
})
}
// پاک‌کردن 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) {
var lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
@@ -382,6 +460,11 @@
function sortByLastSuccess(urls) {
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) {
if (a === lastSuccessUrl) return -1
if (b === lastSuccessUrl) return 1
@@ -665,10 +748,10 @@
'<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>' +
'<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در بارگذاری برنامه</h2>' +
'<p style="margin: 0 0 20px; color: var(--boot-muted); line-height: 2; font-size: 14px;">' +
'ارتباط با سرور برقرار نشد یا فایل‌های برنامه به‌درستی دریافت نشدند.' +
'برقراری ارتباط با سرور امکان‌پذیر نیست. لطفاً اتصال اینترنت خود را بررسی کرده و صفحه را تازه‌سازی نمایید.' +
'</p>' +
'<button onclick="window.__APP_BOOT_SIMPLE_RETRY__()" style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent); color: white; cursor: pointer; font-weight: 700;">' +
'تلاش مجدد' +
'تازه‌سازی صفحه' +
'</button>' +
'<button onclick="window.__APP_BOOT_CLEAN_RELOAD__()" style="margin-right: 8px; padding: 11px 18px; border: 1px solid var(--boot-border); border-radius: 12px; background: transparent; color: var(--boot-text); cursor: pointer; font-weight: 700;">' +
'پاک‌سازی و تلاش مجدد' +
@@ -680,8 +763,8 @@
async function cleanFetchAndReload() {
renderRecovering('در حال دریافت تنظیمات جدید برنامه...')
var version = await loadRemoteVersion(5)
var freshConfigResult = await fetchFreshConfig(5)
var version = await loadRemoteVersion(3)
var freshConfigResult = await fetchFreshConfig(3)
clearReadableCookies()
// 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>
</AConfigProvider>
<div
v-if="showSupportButton && !showSplash && route.name !== 'support'"
v-if="showSupportButton && !showSplash && route.name !== 'support' && !isTvBoardRoute"
id="support-button"
ref="supportButtonEl"
class="fixed z-[5100] cursor-grab select-none rounded-full"
@@ -71,15 +71,19 @@
</div>
</div>
<div
v-if="!showSplash && !isDesktop"
v-if="!showSplash && !isDesktop && !isTvBoardRoute"
class="app-credit-footer app-credit-footer--mobile"
:class="{ 'app-credit-footer--login': isLoginRoute }"
>
<EnamadBadge v-if="isLoginRoute" size="sm" />
<AppCreditLink variant="mobile" />
</div>
<div
v-if="!showSplash && showDesktopFloatingCredit"
v-if="!showSplash && showDesktopFloatingCredit && !isTvBoardRoute"
class="app-credit-footer app-credit-footer--desktop-float"
:class="{ 'app-credit-footer--login': isLoginRoute }"
>
<EnamadBadge v-if="isLoginRoute" size="sm" />
<AppCreditLink variant="float" />
</div>
@@ -93,7 +97,7 @@
<VersionUpdateModal />
<PwaInstallBanner
v-if="!showSplash"
v-if="!showSplash && !isTvBoardRoute"
:visible="bannerVisible && !guideOpen"
@install="openGuide()"
@dismiss="dismissBanner()"
@@ -104,6 +108,8 @@
:platform="platform"
@complete="completeGuide()"
/>
<GoldDayCalculatorModal />
</template>
<script lang="ts" setup>
@@ -118,12 +124,14 @@ import { useSupportMessenger } from '@/composables/useSupportMessenger'
import { loadSupportMessengerFromSettings } from '@/utils/supportMessenger'
import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton'
import AppCreditLink from '@/components/AppCreditLink.vue'
import EnamadBadge from '@/components/EnamadBadge.vue'
import SupportMessengerPicker from '@/components/SupportMessengerPicker.vue'
import { useRoute } from 'vue-router'
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
import { usePwaInstall } from '@/composables/usePwaInstall'
import PwaInstallBanner from '@/components/pwa/PwaInstallBanner.vue'
import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue'
import GoldDayCalculatorModal from '@/components/GoldDayCalculatorModal.vue'
import { useUserStore } from '@/store/userStore'
import { useSettingsStore } from '@/store/settingsStore'
import {
@@ -149,9 +157,19 @@ const showDesktopFloatingCredit = computed(() => {
if (!isDesktop.value) return false
return route.name === 'login' || !!route.meta.hideMenu
})
const isLoginRoute = computed(() => route.name === 'login')
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 {
@@ -404,19 +422,34 @@ watch(latestIncomingNotification, (payload) => {
margin-left: auto;
margin-right: auto;
min-height: var(--app-credit-bar-inner);
padding-bottom: var(--app-safe-bottom);
padding: 4px 0 var(--app-safe-bottom);
background: #fff;
box-shadow: 0 -2px 4px -2px #0000001a;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
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 {
left: auto;
right: 20px;
bottom: 16px;
width: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.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() {
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> => {
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> => {
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 { initPriceRealtimeChannel } from '@/utils/priceSocket'
import { initMobileScrollRestoration } from '@/layouts/composables/useMobileScrollReset'
import { initMobileInputKeyboardScroll } from '@/utils/mobileInputKeyboardScroll'
import { isServerUnavailableError } from '@/utils/serverUnavailable'
type BootWindow = Window & {
__BOOT_TIMEOUT__?: number
@@ -53,13 +55,13 @@ function showBootstrapError(error: unknown) {
<div style="font-size: 42px; margin-bottom: 12px;">⚠️</div>
<h2 style="margin: 0 0 12px; font-size: 20px;">خطا در راه‌اندازی برنامه</h2>
<p style="margin: 0 0 20px; color: var(--boot-muted, rgba(15,23,42,0.62)); line-height: 2; font-size: 14px;">
لطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید.
برقراری ارتباط با سرور امکان‌پذیر نیست. لطفاً اتصال اینترنت خود را بررسی کرده و صفحه را تازه‌سازی نمایید.
</p>
<button
onclick="location.reload()"
style="padding: 11px 22px; border: 0; border-radius: 12px; background: var(--boot-accent, #2563eb); color: white; cursor: pointer; font-weight: 700;"
>
تلاش مجدد
تازه‌سازی صفحه
</button>
</div>
</div>
@@ -124,12 +126,13 @@ export async function bootstrapApp() {
setupRouterGuards()
initMobileScrollRestoration()
initMobileInputKeyboardScroll()
app.mount('#app')
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' })
}
@@ -138,6 +141,10 @@ export async function bootstrapApp() {
sessionStorage.removeItem('__APP_BOOT_RECOVERY_DONE__')
sessionStorage.removeItem('__APP_BOOT_RETRY_CLICKED__')
} catch (error) {
if (isServerUnavailableError(error)) {
clearBootTimeout()
return
}
showBootstrapError(error)
}
}
+39 -1
View File
@@ -8,7 +8,7 @@ import { useAuthStore } from '@/store/useAuthStore'
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
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) {
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') {
const { useSettingsStore } = await import('@/store/settingsStore')
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
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/fund/desktop/styles/desktop-fund.less';
@import (less) '../../views/base/gold/desktop/styles/desktop-gold.less';
@import (less) './mobile-keyboard-scroll.less';
html {
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>
withDefaults(
defineProps<{
variant?: 'mobile' | 'sidebar' | 'float'
variant?: 'mobile' | 'sidebar' | 'float' | 'tv'
}>(),
{ variant: 'mobile' },
)
@@ -16,7 +16,14 @@
</style>-->
<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">
<span class="rounded-sm w-12 border-t-2 border-t-solid border-t-gray-500 mt-2"></span>
</div>
+17 -9
View File
@@ -38,7 +38,11 @@
import {usePrefix} from "@/utils/usePrefix";
import {computed} from "vue";
import {usePrice} from "@/utils/usePrice";
import {persianToEnglishDigits, shouldNormalizeInputDigits} from '@/utils/persianDigits';
import {
normalizeDecimalInput,
persianToEnglishDigits,
shouldNormalizeDecimalSeparators,
} from '@/utils/persianDigits';
//Props
interface Prop {
@@ -81,19 +85,23 @@ function normalizeValue(value: unknown) {
return value
}
let normalized = String(value)
if (shouldNormalizeInputDigits(props)) {
normalized = persianToEnglishDigits(normalized)
if (shouldNormalizeDecimalSeparators(props)) {
const isPrice = String(props.type ?? '').trim().toLowerCase() === 'price'
return normalizeDecimalInput(value as string | number, {
commaAsThousand: isPrice,
})
}
if (props.type === 'price') {
normalized = normalized.replaceAll(',', '')
}
return normalized
return persianToEnglishDigits(String(value))
}
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)
if (target && typeof normalized === 'string' && target.value !== normalized) {
target.value = normalized
}
emits('update:value', normalized)
emits('onchange', normalized)
}
@@ -10,7 +10,7 @@
import {usePrice} from '@/utils/usePrice'
import {computed} from "vue";
import BasicInput from "../../BasicInput.vue";
import {persianToEnglishDigits} from '@/utils/persianDigits'
import {normalizeDecimalInput} from '@/utils/persianDigits'
interface Props {
value?: string | number
@@ -24,7 +24,7 @@ const props = withDefaults(defineProps<Props>(), {})
const _val = computed({
set(val: string) {
emit('update:value', persianToEnglishDigits(val).replaceAll(',', ''))
emit('update:value', normalizeDecimalInput(val))
},
get() {
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 { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useConfigStore } from '@/store/configStore'
import {
getProjectBaleContactLink,
@@ -26,6 +26,7 @@ import {
} from '@/utils/supportMessenger'
import {
copyDepositFieldValue,
displayDepositAccountNumber,
displayDepositCardNumber,
displayDepositSheba,
getDepositGuideVarsFromSettings,
@@ -51,10 +52,15 @@ export function useSupportMessenger() {
const config = useConfigStore()
const settingsStore = useSettingsStore()
const router = useRouter()
const route = useRoute()
const supportPickerOpen = supportMessengerPickerOpen
const supportPickerIntent = ref<SupportPickerIntent>('plain')
const isChatEnabled = computed(() => isUserSupportChatEnabled())
/** چت فقط بعد از لاگین — روی صفحه ورود در picker نیاید */
const isChatEnabled = computed(() => {
void route.name
return isUserSupportChatEnabled()
})
const legacySupportLink = computed(() => {
if (config.config?.name === 'garoosi' || isBazretalaProject()) {
@@ -223,6 +229,13 @@ export function useSupportMessenger() {
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(() => {
void settingsStore.settings
const fromMessenger = supportMessengerSettings.value.depositSheba?.trim()
@@ -231,6 +244,9 @@ export function useSupportMessenger() {
})
const depositCardNumber = computed(() => displayDepositCardNumber(depositCardNumberRaw.value))
const depositAccountNumber = computed(() =>
displayDepositAccountNumber(depositAccountNumberRaw.value),
)
const depositSheba = computed(() => displayDepositSheba(depositShebaRaw.value))
const depositCardLabel = computed(() => {
@@ -238,12 +254,20 @@ export function useSupportMessenger() {
return getDepositSettingLabel('deposit_card_number', 'شماره کارت واریز')
})
const depositAccountNumberLabel = computed(() => {
void settingsStore.settings
return getDepositSettingLabel('deposit_account_number', 'شماره حساب واریز')
})
const depositShebaLabel = computed(() => {
void settingsStore.settings
return getDepositSettingLabel('deposit_sheba', 'شبا واریز')
})
const hasDepositCardValue = computed(() => hasDepositFieldValue(depositCardNumberRaw.value))
const hasDepositAccountNumberValue = computed(() =>
hasDepositFieldValue(depositAccountNumberRaw.value),
)
const hasDepositShebaValue = computed(() => hasDepositFieldValue(depositShebaRaw.value))
async function copyDepositCardNumber() {
@@ -254,6 +278,14 @@ export function useSupportMessenger() {
)
}
async function copyDepositAccountNumber() {
await copyDepositFieldValue(
depositAccountNumberRaw.value,
'account',
'شماره حساب ثبت نشده است',
)
}
async function copyDepositShebaNumber() {
await copyDepositFieldValue(
depositShebaRaw.value,
@@ -283,14 +315,19 @@ export function useSupportMessenger() {
supportMessengerText,
depositSupportMessage,
depositCardNumber,
depositAccountNumber,
depositSheba,
depositCardNumberRaw,
depositAccountNumberRaw,
depositShebaRaw,
depositCardLabel,
depositAccountNumberLabel,
depositShebaLabel,
hasDepositCardValue,
hasDepositAccountNumberValue,
hasDepositShebaValue,
copyDepositCardNumber,
copyDepositAccountNumber,
copyDepositShebaNumber,
openSupportMessenger,
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', 'اپ مشتری')
}
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) {
if (/^(https?:\/\/|data:|blob:)/i.test(path)) return path
const lastSuccess = localStorage.getItem('CONFIG_Success_URL')
const base = lastSuccess
? lastSuccess.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '')
: 'https://nerkh.gold'
const base = lastSuccess ? resolveConfigBase(lastSuccess) : 'https://adm.montzar.ir'
return `${base}/${path.replace(/^\/+/, '')}`
}
+61
View File
@@ -1,9 +1,11 @@
import type { AppConfig } from '@/config/types'
import { resetVersionCheckCache } from '@/config/load-version'
import { clearCachedPwaManifest } from '@/modules/pwa/manifest-cache'
import { resolveTenantDomain } from '@/config/superadmin'
import { collectAuthTokensForPreserve } from '@/utils/authToken'
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 VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
const CONFIG_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
@@ -18,6 +20,13 @@ type CachedConfigPayload = {
config: AppConfig
}
export type VersionedAppConfigCache = {
version: string
domain: string
cached_at: number
config: AppConfig
}
type VersionUpdateLock = {
version: string
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) {
try {
const payload: CachedConfigPayload = {
@@ -84,6 +141,7 @@ export function writeCachedAppConfig(config: AppConfig) {
export function clearCachedAppConfig() {
document.cookie = `${CONFIG_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax`
clearVersionedAppConfigCache()
clearCachedPwaManifest()
}
@@ -223,6 +281,7 @@ export function clearAppStorageExceptToken() {
export function clearAllBootstrapFetchCaches() {
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
clearVersionedAppConfigCache()
clearCachedPwaManifest()
resetVersionCheckCache()
window.__APP_CONFIG__ = undefined
@@ -253,6 +312,7 @@ export async function refreshConfigCacheForVersion(
await clearBrowserRuntimeCaches()
writeCachedAppConfig(freshConfig)
writeVersionedAppConfigCache(freshConfig, normalizedVersion, resolveTenantDomain())
finishVersionUpdateBeforeReload(normalizedVersion)
return freshConfig
@@ -275,6 +335,7 @@ export async function resetAppAndRefreshConfig(
if (normalizedVersion) {
writeStoredAppVersion(normalizedVersion)
writeVersionedAppConfigCache(freshConfig, normalizedVersion, resolveTenantDomain())
}
return freshConfig
+220 -34
View File
@@ -1,33 +1,54 @@
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'
export const CONFIG_RETRY_ROUNDS = 5
const VERSION_SUCCESS_URL_KEY = 'VERSION_Success_URL'
export const CONFIG_RETRY_ROUNDS = SA_RETRY_ROUNDS
function getProtocol() {
return window.location.protocol === 'https:' ? 'https:' : 'http:'
}
export { getSuperAdminBase, resolveTenantDomain } from '@/config/superadmin'
/** فقط SuperAdmin — بدون رجیستری قدیمی */
export function getConfigUrls() {
const protocol = getProtocol()
return [
`${protocol}//nerkh.gold/get_user`,
`${protocol}//tablo.talasevin.ir/get_user`,
`${protocol}//abshode-service.montgit.ir/server/get_user`,
`${protocol}//212.23.201.173:8381/get_user`,
`${protocol}//91.212.174.137:4040/server/get_user`
]
const domain = encodeURIComponent(resolveTenantDomain())
return getSuperAdminBases().map((base) => `${base}/get_user?domain=${domain}`)
}
/** اولویت با last successful SuperAdmin؛ رجیستری قدیمی sticky نشود */
export function sortConfigUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(CONFIG_SUCCESS_URL_KEY)
return [...urls].sort((a, b) => {
if (a === lastSuccessUrl) return -1
if (b === lastSuccessUrl) return 1
return 0
})
if (
!lastSuccessUrl ||
!urls.includes(lastSuccessUrl) ||
!isSuperAdminUrl(lastSuccessUrl)
) {
if (lastSuccessUrl && !isSuperAdminUrl(lastSuccessUrl)) {
localStorage.removeItem(CONFIG_SUCCESS_URL_KEY)
}
return [...urls]
}
return [lastSuccessUrl, ...urls.filter((url) => url !== lastSuccessUrl)]
}
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> {
const controller = new AbortController()
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, {
method: 'GET',
headers: {
Accept: 'application/json'
Accept: 'application/json',
},
cache: 'no-store',
signal: controller.signal
signal: controller.signal,
})
if (!response.ok) {
@@ -60,14 +97,17 @@ async function fetchConfig(url: string, timeout = 3000): Promise<AppConfig> {
throw new Error('Config response is not JSON')
}
return await response.json()
return assertValidAppConfig(await response.json(), url)
} finally {
clearTimeout(timeoutId)
}
}
export async function fetchFreshAppConfig(rounds = CONFIG_RETRY_ROUNDS): Promise<AppConfig> {
const urls = sortConfigUrls(getConfigUrls())
async function tryFetchFromUrls(
urls: string[],
rounds: number,
): Promise<AppConfig | null> {
if (!urls.length) return null
for (let round = 1; round <= rounds; round++) {
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 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)
console.info('[config] loaded from SuperAdmin:', url, (config as { user?: { name?: string } })?.user?.name)
return config
} catch (error) {
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> {
const cachedConfig = readCachedAppConfig()
if (cachedConfig) {
return cachedConfig
export async function fetchFreshAppConfig(
rounds = CONFIG_RETRY_ROUNDS,
options?: { skipPurge?: boolean },
): Promise<AppConfig> {
// 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 =
| string
| number
@@ -9,36 +17,25 @@ type VersionResponse =
}
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 cachedVersionResult: string | null | undefined
function getProtocol() {
return window.location.protocol === 'https:' ? 'https:' : 'http:'
}
/** فقط SuperAdmin — بدون رجیستری قدیمی */
function getVersionUrls() {
const protocol = getProtocol()
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`
]
const domain = encodeURIComponent(resolveTenantDomain())
return getSuperAdminBases().map((base) => `${base}/get_version?domain=${domain}`)
}
function sortVersionUrls(urls: string[]) {
const lastSuccessUrl = localStorage.getItem(VERSION_SUCCESS_URL_KEY)
if (!lastSuccessUrl) {
return urls
if (!lastSuccessUrl || !urls.includes(lastSuccessUrl) || !isSuperAdminUrl(lastSuccessUrl)) {
if (lastSuccessUrl && !isSuperAdminUrl(lastSuccessUrl)) {
localStorage.removeItem(VERSION_SUCCESS_URL_KEY)
}
return [...urls]
}
const others = urls.filter((url) => url !== lastSuccessUrl)
return [lastSuccessUrl, ...others]
return [lastSuccessUrl, ...urls.filter((url) => url !== lastSuccessUrl)]
}
function clearStaleVersionSuccessUrl(url: string) {
@@ -89,10 +86,10 @@ async function fetchVersion(url: string, timeout = 3500): Promise<string | null>
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json'
Accept: 'application/json',
},
cache: 'no-store',
signal: controller.signal
signal: controller.signal,
})
if (!response.ok) {
@@ -100,10 +97,11 @@ async function fetchVersion(url: string, timeout = 3500): Promise<string | null>
}
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')
? ((await response.json()) as VersionResponse)
: ((await response.text()) as VersionResponse)
const data = (await response.json()) as VersionResponse
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)
return version
} finally {
window.clearTimeout(timeoutId)
}
}
async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<string | null> {
const urls = sortVersionUrls(getVersionUrls())
async function tryFetchVersionFromUrls(
urls: string[],
rounds: number,
): Promise<string | null> {
if (!urls.length) return null
for (let round = 1; round <= rounds; round++) {
for (let index = 0; index < urls.length; index++) {
@@ -142,8 +142,27 @@ async function fetchVersionWithRetry(rounds = VERSION_RETRY_ROUNDS): Promise<str
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() {
cachedVersionResult = undefined
versionFetchPromise = null
}
@@ -158,22 +177,13 @@ export async function loadRemoteAppVersion(options?: {
resetVersionCheckCache()
}
if (cachedVersionResult !== undefined) {
return cachedVersionResult
}
if (versionFetchPromise) {
return versionFetchPromise
}
versionFetchPromise = fetchVersionWithRetry(rounds)
.then((version) => {
cachedVersionResult = version
return version
})
.finally(() => {
versionFetchPromise = null
})
versionFetchPromise = fetchVersionWithRetry(rounds).finally(() => {
versionFetchPromise = null
})
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 { useSettingsStore } from '@/store/settingsStore'
import { getProjectSplashLogo } from '@/utils/config'
import { featureSettingForRoute } from '@/utils/superAdminMenuGates'
import { getPrimaryNavItems, SECONDARY_NAV } from '../config/navigation'
import { useAppNavigation } from '../composables/useAppNavigation'
import DesktopNavItem from './DesktopNavItem.vue'
@@ -55,12 +56,19 @@ import AppCreditLink from '@/components/AppCreditLink.vue'
const config = useConfigStore()
const settingsStore = useSettingsStore()
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(() =>
SECONDARY_NAV.filter((item) => {
if (item.id === 'notifyTrigger') {
return settingsStore.isSettingTruthy('has_page_price_trigger')
}
const featureKey = featureSettingForRoute(item.routeName)
if (featureKey) return settingsStore.isSettingTruthy(featureKey)
return true
}),
)
+4 -1
View File
@@ -3,9 +3,10 @@ import './assets/css/main.less'
import 'virtual:uno.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 { initSafeAreaInsets } from '@/utils/safeAreaInsets'
import { preloadLottieAssets } from '@/utils/lottieAssets'
import { bootstrapApp } from '@/app/bootstrap'
export {
@@ -16,5 +17,7 @@ export {
initSafeAreaInsets()
initPwaInstallCapture()
applyCachedPwaChromeEarly()
initPwaInstallPromptListener()
void preloadLottieAssets()
void bootstrapApp()
+2
View File
@@ -1,5 +1,7 @@
export interface PriceTrigger {
id?: number
product_id?: number
product?: { id: number; title?: string }
target_price: string
price_condition: string
action: string
+4
View File
@@ -31,4 +31,8 @@ export interface Price {
time?: string
main_product?: 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
show: boolean
show_in_assets?: boolean
show_on_tv?: boolean
open_sell: boolean
open_buy: boolean
group_type:string
@@ -28,6 +29,8 @@ export interface Product {
has_physical_delivery?: boolean
min_physical_delivery_quantity?: number | null
direct_price: number | undefined
price_formula_enabled?: boolean | number | null
price_formula?: string | null
price_offset_percent?: number | null
use_last_price_if_zero?: boolean
reference_provider_active?: boolean
+1 -1
View File
@@ -14,7 +14,7 @@ export interface Transaction {
unit: string
hash?: string
id?: number
note?: null
note?: string | null
title: string
order_id?: number
transaction_type?: string
+37
View File
@@ -22,6 +22,8 @@ export type PwaManifest = {
const MANIFEST_CACHE_KEY = 'PWA_MANIFEST_CACHE_V5'
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 = {
cached_at: number
@@ -69,4 +71,39 @@ export function writeCachedPwaManifest(entry: CachedPwaManifest) {
export function clearCachedPwaManifest() {
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 { getConfig, getRawAppConfig } from '@/config/app-config'
import { getConfigUrls, sortConfigUrls } from '@/config/load-config'
import {
getSuperAdminBase,
isSuperAdminUrl,
resolveTenantDomain,
} from '@/config/superadmin'
import {
clearCachedPwaManifest,
readAnyFreshCachedPwaManifest,
readCachedPwaManifest,
writeCachedPwaInstallIconUrl,
writeCachedPwaManifest,
type PwaManifest
} from '@/modules/pwa/manifest-cache'
@@ -22,8 +29,26 @@ function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
/** پایهٔ سرور از URLهای get_user یا manifest — query مثل ?domain= را حذف می‌کند */
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) */
@@ -33,15 +58,31 @@ export function getConfigBaseFromStorage() {
return normalizeBaseUrl(lastSuccess)
}
function configUrlToManifestUrl(configUrl: string) {
return `${configUrl.replace(/\/+$/, '')}/manifest.json`
function getSuperAdminManifestUrl(): string | null {
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[] {
const explicit = getConfig<string>('manifest_url')
if (explicit) return [explicit]
const saManifest = getSuperAdminManifestUrl()
if (saManifest) {
return [saManifest]
}
return sortConfigUrls(getConfigUrls()).map(configUrlToManifestUrl)
}
@@ -222,8 +263,12 @@ function mapManifestIcons(manifest: PwaManifest, base: string) {
}
/** فقط آیکون‌های داخل monifist.json — بدون آواتار / pwa-icons / env */
function resolveInstallIcons(manifest: PwaManifest) {
const base = getConfigBaseFromStorage()
function resolveInstallIcons(manifest: PwaManifest, manifestUrl?: string) {
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)
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 startUrl = toSameOriginManifestUrl(manifest.start_url, '/')
const icons = resolveInstallIcons(manifest)
const icons = resolveInstallIcons(manifest, manifestUrl)
const name = manifest.short_name || manifest.name
if (!name) {
@@ -288,6 +333,60 @@ function pickManifestIconUrl(manifest: PwaManifest, preferredSizes: number[]) {
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>) {
const selectorParts = Object.entries(attrs)
.filter(([key]) => key !== 'href')
@@ -351,6 +450,7 @@ function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
upsertLink('apple-touch-icon', {
'data-dynamic-pwa': 'true',
'data-pwa-icon-src': href,
sizes: `${size}x${size}`,
href
})
@@ -359,8 +459,10 @@ function applyAppleTouchIconsFromManifest(manifest: PwaManifest) {
const primary = pickManifestIconUrl(manifest, [180, 192, 512, 384])
if (primary) {
writeCachedPwaInstallIconUrl(primary)
upsertLink('apple-touch-icon', {
'data-dynamic-pwa': 'true',
'data-pwa-icon-src': primary,
href: primary
})
}
@@ -410,12 +512,14 @@ export async function applyPreparedManifestToDocument(
console.info('PWA manifest applied:', manifestUrl, `icons: ${normalized.icons.length}`)
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) {
const prepared = prepareManifestForInstall(manifest)
const prepared = prepareManifestForInstall(manifest, manifestUrl)
await applyPreparedManifestToDocument(prepared, manifestUrl)
}
@@ -431,6 +535,25 @@ export function isPwaManifestReady() {
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 */
export function initPwaInstallCapture() {
if (installCaptureInitialized || typeof window === 'undefined') return
@@ -476,7 +599,7 @@ async function applyCachedManifestEntry(
if (!cached) return false
try {
const prepared = prepareManifestForInstall(cached.manifest)
const prepared = prepareManifestForInstall(cached.manifest, cached.manifest_url)
await applyPreparedManifestToDocument(prepared, cached.manifest_url)
setPwaManifestReady(true)
return true
@@ -514,7 +637,7 @@ export async function setupPwaManifest(config?: AppConfig | null): Promise<boole
try {
const { manifest, manifestUrl } = await fetchManifestTryingUrls(manifestUrls)
const prepared = prepareManifestForInstall(manifest)
const prepared = prepareManifestForInstall(manifest, manifestUrl)
await applyPreparedManifestToDocument(prepared, manifestUrl)
setPwaManifestReady(true)
@@ -36,6 +36,7 @@ import {
cancelVersionUpdateLock,
hardReloadApp
} from '@/config/config-cache'
import { assertServerReachableConfig, isServerUnreachableConfig, isServerUnavailableError } from '@/utils/serverUnavailable'
const isUpdating = ref(false)
const hasError = ref(false)
@@ -86,6 +87,11 @@ async function startUpdate(version: string) {
const freshConfig = await refreshConfigCacheForVersion(version, fetchFreshAppConfig)
if (isServerUnreachableConfig(freshConfig)) {
assertServerReachableConfig(freshConfig)
return
}
setAppConfig(freshConfig)
await wait(500)
@@ -97,6 +103,11 @@ async function startUpdate(version: string) {
} catch (error) {
console.error('Version update failed:', error)
if (isServerUnavailableError(error)) {
cancelVersionUpdateLock()
return
}
if (configBackup) {
window.__APP_CONFIG__ = configBackup
setAppConfig(configBackup)
+21
View File
@@ -8,6 +8,17 @@ const router = createRouter({
name: 'login',
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: '/',
name: 'null',
@@ -117,6 +128,11 @@ const router = createRouter({
name: 'giftCard',
component: () => import('@/views/base/giftCard/index.vue')
},
{
path: 'recoverySites',
name: 'recoverySites',
component: () => import('@/views/base/recoverySites/index.vue')
},
{
path: 'fund',
name: 'fund',
@@ -155,6 +171,11 @@ const router = createRouter({
name: 'aboutUs',
component: () => import('@/views/base/aboutUs/index.vue')
},
{
path: '/about-software',
name: 'aboutSoftware',
component: () => import('@/views/base/aboutSoftware/index.vue')
},
{
path: '/:pathMatch(.*)*',
name: 'notFound',
+36 -13
View File
@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { getSettings } from '@/api/configuration'
import { fetchSuperAdminProgramSettings, isLockedCanEdit } from '@/api/superadminSettings'
import { resolveMeltedGoldLabel, resolveMetalTypeLabel } from '@/utils/metalLabels'
import { resolveTradeWeightUnit } from '@/utils/weightUnit'
import { runOnceAsync } from '@/composables/useOnceAsync'
@@ -13,11 +14,13 @@ export interface AppSetting {
description?: string
is_active?: boolean
type?: string
can_edit?: number | boolean | string
}
export const useSettingsStore = defineStore('settings', () => {
const settings = ref<AppSetting[]>([])
const loaded = ref(false)
const settingsRevision = ref(0)
function getSettingByKey(key: string) {
const item = settings.value.find((setting) => setting?.key === key)
@@ -42,6 +45,7 @@ export const useSettingsStore = defineStore('settings', () => {
}
const merged = [...settings.value]
let changed = false
items.forEach((item) => {
if (!item?.key) {
return
@@ -49,27 +53,37 @@ export const useSettingsStore = defineStore('settings', () => {
const index = merged.findIndex((setting) => setting.key === item.key)
if (index >= 0) {
merged[index] = { ...merged[index], ...item }
} else {
merged.push(item)
const current = merged[index]
if (current.value === item.value && current.is_active === item.is_active) {
return
}
merged[index] = { ...current, ...item }
changed = true
return
}
merged.push(item)
changed = true
})
if (!changed) {
return
}
settings.value = merged
settingsRevision.value += 1
}
async function fetchSettings(force = false) {
return runOnceAsync('settings:all', async () => {
try {
const data = await getSettings()
if (Array.isArray(data)) {
mergeSettings(data)
}
} catch (e) {
console.log(e)
} finally {
loaded.value = true
}
const localRaw = await getSettings().catch(() => [])
const remote = await fetchSuperAdminProgramSettings()
const local = Array.isArray(localRaw) ? localRaw : []
const editable = local.filter((row) => !isLockedCanEdit(row?.can_edit))
settings.value = []
mergeSettings(remote)
mergeSettings(editable)
loaded.value = true
return settings.value
}, force)
}
@@ -78,7 +92,14 @@ export const useSettingsStore = defineStore('settings', () => {
const metalTypeLabel = computed(() => resolveMetalTypeLabel(metalTypeSettingValue.value))
const meltedGoldLabel = computed(() => resolveMeltedGoldLabel(metalTypeSettingValue.value))
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(() => {
if (!isReferralFieldVisible.value) return false
const value = getSettingByKey('referral_id')
return String(value ?? '').trim().toLowerCase() === 'required'
})
@@ -86,11 +107,13 @@ export const useSettingsStore = defineStore('settings', () => {
return {
settings,
loaded,
settingsRevision,
fetchSettings,
mergeSettings,
getSettingByKey,
isSettingTruthy,
isReferralRequired,
isReferralFieldVisible,
metalTypeSettingValue,
metalTypeLabel,
meltedGoldLabel,
+3 -3
View File
@@ -28,11 +28,11 @@ export const useUserStore = defineStore('user', () => {
console.log(e)
}
}
function getValueByKey(keyName) {
const item = userInfo.value.settings?.find(obj => obj.key === keyName);
function getValueByKey(keyName: string) {
const item = userInfo.value?.settings?.find(obj => obj.key === keyName)
// اگر آیتم پیدا شد، فیلد value را برگردان، در غیر این صورت null
return item ? item.value : null;
return item ? item.value : null
}
function removeInformation() {
+3 -2
View File
@@ -3,7 +3,7 @@ import { useWebSocket } from '@vueuse/core'
import { useUserStore } from '@/store/userStore'
import { useSettingsStore } from '@/store/settingsStore'
import { startChat } from '@/api/chat'
import { createUuid } from '@/utils/index'
import { createUuid, getToken } from '@/utils/index'
/** true/1/yes/activate */
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'
}
/** نمایش چت برای کاربر نهایی: سوییچ دیپلوی + سوییچ مشتری */
/** نمایش چت برای کاربر نهایی: لاگین + سوییچ دیپلوی + سوییچ مشتری */
export function isUserSupportChatEnabled(): boolean {
if (!getToken()) return false
const settings = useSettingsStore()
return (
isSettingFlagOn(settings.getSettingByKey('show_to_users_chat')) &&
+19 -4
View File
@@ -5,6 +5,7 @@ import { useUserStore } from '@/store/userStore'
export interface DepositGuideVars {
accountHolder?: string | null
cardNumber?: string | null
accountNumber?: string | null
sheba?: string | null
/** نام مشتری واردشده — برای پیام پشتیبانی واریز */
customerName?: string | null
@@ -19,6 +20,7 @@ export const DEFAULT_DEPOSIT_SUPPORT_MESSAGE_TEXT =
/** الگوی متغیر نام — ٪نام / %نام / % نام / %نام% */
const NAME_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
export function getCustomerNameFromUser(): string {
@@ -45,17 +47,19 @@ export function renderDepositSupportMessageText(
return template.replace(NAME_PLACEHOLDER_RE, name)
}
/** جایگزینی متغیرها در متن راهنمای واریز — ٪نام / %نام، ٪کارت / %کارت، ٪شبا / %شبا */
/** جایگزینی متغیرها در متن راهنمای واریز — ٪نام / %نام، ٪کارت / %کارت، ٪حساب / %حساب، ٪شبا / %شبا */
export function renderDepositGuideText(
template: string,
vars: DepositGuideVars,
): string {
const accountHolder = String(vars.accountHolder ?? '').trim()
const cardNumber = String(vars.cardNumber ?? '').trim()
const accountNumber = String(vars.accountNumber ?? '').trim()
const sheba = String(vars.sheba ?? '').trim()
return template
.replace(SHEBA_PLACEHOLDER_RE, sheba)
.replace(ACCOUNT_PLACEHOLDER_RE, accountNumber)
.replace(CARD_PLACEHOLDER_RE, cardNumber)
.replace(NAME_PLACEHOLDER_RE, accountHolder)
}
@@ -67,6 +71,7 @@ export function getDepositGuideVarsFromSettings(): DepositGuideVars {
return {
accountHolder: store.getSettingByKey('deposit_account_holder'),
cardNumber: store.getSettingByKey('deposit_card_number'),
accountNumber: store.getSettingByKey('deposit_account_number'),
sheba: store.getSettingByKey('deposit_sheba'),
customerName,
}
@@ -115,6 +120,12 @@ export function formatDepositCardNumber(value?: string | null) {
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) {
const normalized = String(value ?? '').replace(/\s/g, '').toUpperCase()
if (!normalized) return ''
@@ -134,6 +145,10 @@ export function displayDepositCardNumber(value?: string | null) {
return formatDepositCardNumber(value) || '**** **** **** ****'
}
export function displayDepositAccountNumber(value?: string | null) {
return formatDepositAccountNumber(value) || '—'
}
export function displayDepositSheba(value?: string | null) {
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, '')
if (!normalized) return ''
@@ -160,12 +175,12 @@ function normalizeDepositCopyValue(value: string, kind: 'card' | 'sheba') {
return digits ? `IR${digits}` : upper
}
return normalized.replace(/\D/g, '')
return normalized.replace(/\D/g, '') || normalized
}
export async function copyDepositFieldValue(
value: string | null | undefined,
kind: 'card' | 'sheba',
kind: 'card' | 'account' | 'sheba',
emptyMessage: string,
) {
const raw = String(value ?? '').trim()
+65 -10
View File
@@ -8,28 +8,83 @@ export interface ProductFile {
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 {
if (!file) return null
for (const value of [file.full_src, file.url, file.src]) {
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
if (!relative) return null
const base = resolveProjectBase().replace(/\/$/, '')
if (!base) return relative.startsWith('/') ? relative : `/${relative}`
const path = String(relative).replace(/^\//, '')
return `${base}/${path}`
return null
}
/** Resolve profile avatar URL from GET /customer/me payload */
export function getUserAvatarUrl(user?: User | null): string | null {
if (!user) return null
if (typeof user.avatar_url === 'string' && user.avatar_url) return user.avatar_url
if (typeof user.full_src === 'string' && user.full_src) return user.full_src
if (typeof user.avatar_url === 'string' && user.avatar_url) {
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
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, '.')
}
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
type?: string
inputmode?: string
}): 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()
if (type === 'price' || type === 'number' || type === 'tel') {
return true
}
if (type === 'price' || type === 'number') return true
const mode = String(options.inputmode ?? '').trim().toLowerCase()
return mode === 'numeric' || mode === 'decimal' || mode === 'number'
}
+100 -3
View File
@@ -7,6 +7,8 @@ import {
priceWssEnabled,
shouldUsePricePolling,
} from '@/utils/priceSocketState'
import { applyBoardSpecialToLastPayload } from '@/utils/specialPricing'
import { useSettingsStore, type AppSetting } from '@/store/settingsStore'
const LAST_INTERVAL_MS = 10000
@@ -16,10 +18,21 @@ let lastSubscribers = 0
/** @deprecated از lastPrice استفاده کنید — برای سازگاری با کد قدیمی sync می‌شود */
export const price = 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)
const QUOTE_ABSENCE_SETTLE_MS = 2500
let lastPriceSourceAt = 0
let consecutivePriceFetchFailures = 0
let lastPriceReadyAt = 0
let quoteSettleTimer: ReturnType<typeof setTimeout> | undefined
function hasValidPricePayload(data?: Price | null): boolean {
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() {
lastPriceSourceAt = Date.now()
consecutivePriceFetchFailures = 0
void import('@/utils/priceSocket').then(({ clearPriceNetworkAlert }) => {
clearPriceNetworkAlert()
})
}
export function markPriceSourceFetchFailed() {
consecutivePriceFetchFailures += 1
if (consecutivePriceFetchFailures >= 2) {
void import('@/utils/priceSocket').then(({ notifyPriceLastFetchFailed }) => {
notifyPriceLastFetchFailed()
})
}
}
export function hasRecentPriceFromSource(maxAgeMs = 45000): boolean {
@@ -163,18 +240,38 @@ export async function getLastPrices(): Promise<boolean> {
console.log(e)
markPriceSourceFetchFailed()
return false
} finally {
markLastPriceReady()
}
}
export function applyLastPricePayload(dataLast: Price) {
const normalized = normalizePriceResponse(dataLast)
if (lastPrice.value?.price_last != normalized.price_last) {
applyTradingHoursSettingsPatch(dataLast)
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
}
lastPrice.value = normalized
markLastPriceReady()
price.value = {
...normalized,
buy: getTradeBuyPrice(normalized),
buy: nextBuy,
sell: getTradeSellPrice(normalized),
}
+190 -40
View File
@@ -9,6 +9,10 @@ import {
priceStreamLifecycle,
priceWssEnabled,
} from '@/utils/priceSocketState'
import {
applyBoardSpecialToLastPayload,
setSpecialPricingPayload,
} from '@/utils/specialPricing'
export {
priceSocketConnected,
@@ -20,7 +24,8 @@ export {
const RECONNECT_BASE_MS = 3000
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 FALLBACK_POLL_INTERVAL_MS = 10000
/** بدون قیمت معتبر از مرجع (SSE یا API) */
@@ -29,8 +34,95 @@ const PRICE_SOURCE_STALE_MS_DESKTOP = 35000
/** بدون event قیمت؛ ping هر ۵ث از سرور activity را تازه می‌کند */
const STALE_STREAM_MS = 120000
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 stopped = false
let getSetting: ((key: string) => unknown) | null = null
@@ -99,16 +191,12 @@ function resolveStreamUrl(): string | null {
return null
}
const token = findStoredAuthToken()
if (!token) {
if (!findStoredAuthToken()) {
return null
}
const base = String(getConfigApp('node_server') || window.location.origin).replace(/\/$/, '')
const url = new URL(`${base}${resolveStreamPath()}`)
url.searchParams.set('token', token)
return url.toString()
return `${base}${resolveStreamPath()}`
}
function clearReconnectTimer() {
@@ -137,7 +225,7 @@ function startStaleCheck() {
stopped
|| subscribers <= 0
|| !eventSource
|| eventSource.readyState !== EventSource.OPEN
|| eventSource.readyState !== STREAM_OPEN
) {
return
}
@@ -190,6 +278,16 @@ function scheduleFallbackPoll() {
}, FALLBACK_POLL_DELAY_MS)
}
export function clearPriceNetworkAlert() {
networkAlertOpen = false
pendingPriceAlert = false
}
/** وقتی /api/price/last خطا می‌دهد (از polling عادی یا fallback) */
export function notifyPriceLastFetchFailed() {
void maybeShowNetworkAlert()
}
async function runFallbackPoll() {
if (!priceWssEnabled.value || priceSocketConnected.value) {
return
@@ -213,8 +311,9 @@ async function runFallbackPoll() {
&& priceResult.value === true
&& hasRecentPriceFromSource(getPriceSourceStaleMs())
if (!priceOk) {
markPriceSourceFetchFailed()
if (priceOk) {
clearPriceNetworkAlert()
return
}
void maybeShowNetworkAlert()
@@ -243,25 +342,7 @@ async function isRealPriceOutage(): Promise<boolean> {
return false
}
const fetchFailures = consecutivePriceSourceFailures()
if (fetchFailures >= 2) {
return true
}
if (!priceSocketConnected.value && fetchFailures >= 1 && reconnectAttempt >= 2) {
return true
}
if (!isMobileDevice() && !navigator.onLine && fetchFailures >= 1) {
return true
}
if (!isMobileDevice() && reconnectAttempt >= FAILURES_BEFORE_ALERT) {
return true
}
return false
return consecutivePriceSourceFailures() >= LAST_FETCH_FAILURES_BEFORE_ALERT
}
function showNetworkAlert() {
@@ -322,7 +403,6 @@ function scheduleReconnect() {
reconnectTimer = setTimeout(() => {
reconnectAttempt += 1
connect()
maybeShowNetworkAlert()
}, delay)
}
@@ -331,13 +411,61 @@ async function applyLastFromStream(last: Price) {
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) {
if (!payload || typeof payload !== 'object') {
return
}
const last = payload as Price
if (last.price_last == null && last.price == null && last.buy == null) {
const raw = payload as Price & { settings_patch?: unknown }
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
}
@@ -345,6 +473,11 @@ function handleLastPayload(payload: unknown) {
void applyLastFromStream(last)
}
function handleSettingsPayload(payload: unknown) {
touchStreamActivity()
void applySettingsFromStream(payload)
}
async function applyListFromStream(products: Product[]) {
const { applyProductsListFromSocket } = await import(
'@/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) => {
try {
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) => {
try {
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', () => {
touchStreamActivity()
})
@@ -493,7 +642,8 @@ function connect() {
}
const url = resolveStreamUrl()
if (!url) {
const token = findStoredAuthToken()
if (!url || !token) {
priceSocketConnected.value = false
priceStreamLifecycle.value = 'closed'
clearConnectingLifecycle()
@@ -502,7 +652,7 @@ function connect() {
if (eventSource) {
const state = eventSource.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
return
}
disconnectSourceOnly()
@@ -511,7 +661,7 @@ function connect() {
markConnectingLifecycle()
try {
eventSource = new EventSource(url)
eventSource = new AuthPriceStream(url, token)
} catch (error) {
console.warn('Price SSE connect failed', error)
handleStreamLost()
@@ -534,7 +684,7 @@ function connect() {
eventSource.onerror = () => {
const state = eventSource?.readyState
if (state === EventSource.OPEN || state === EventSource.CONNECTING) {
if (state === STREAM_OPEN || state === STREAM_CONNECTING) {
return
}
@@ -648,7 +798,7 @@ function onVisibilityChange() {
void maybeShowNetworkAlert()
}
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
if (!eventSource || eventSource.readyState !== STREAM_OPEN) {
forceReconnect()
}
}
@@ -658,7 +808,7 @@ function onPageShow(event: PageTransitionEvent) {
return
}
if (event.persisted || !eventSource || eventSource.readyState !== EventSource.OPEN) {
if (event.persisted || !eventSource || eventSource.readyState !== STREAM_OPEN) {
reconnectAttempt = 0
networkAlertOpen = false
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?: {
group_type?: string | null
title?: string | null
file?: ProductFile | null
file_id?: number | string | null
img?: string | null
} | null): string {
const fileUrl = getFileUrl(product?.file)
if (fileUrl) {
return fileUrl
return withProductImageCacheBust(fileUrl, product)
}
if (product?.img && /^https?:\/\//i.test(product.img)) {
return product.img
return withProductImageCacheBust(product.img, product)
}
const group = normalizeProductGroupType(product?.group_type)
+6 -2
View File
@@ -43,19 +43,23 @@ export function resolveProductCashFeeRial(
}
export function resolveProductCashFeeBuyRial(product: ProductCashFeeSource, basePriceRial: number) {
return resolveProductCashFeeRial(
const baseFee = resolveProductCashFeeRial(
product.cash_fee_buy,
product.cash_fee_buy_is_percent,
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) {
return resolveProductCashFeeRial(
const baseFee = resolveProductCashFeeRial(
product.cash_fee_sell,
product.cash_fee_sell_is_percent,
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(
+6
View File
@@ -59,6 +59,7 @@ export interface ProductUnitSource {
group_type?: string | null
amount?: number | null
trade_by_quantity?: boolean | number | null
price_formula_enabled?: boolean | number | null
}
export function isWeightBasedProductUnit(unit?: string | null) {
@@ -80,6 +81,11 @@ export function hasProductUnitWeight(product?: ProductUnitSource | null) {
/** ضریب قیمت — فقط «متغیر» بدون مرجع اتوماتیک: قیمت هر گرم × وزن */
export function productPriceAmountFactor(product?: ProductUnitSource | null) {
// قیمت فرمولی = قیمت واحد هر عدد (وزن داخل فرمول است)
if (product?.price_formula_enabled) {
return 1
}
if (product?.reading_method) {
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 صفر می‌ماند
* در حالی که محتوا واقعاً زیر نوار وضعیت رسم می‌شود. این تابع مقدار واقعی را
@@ -36,16 +55,10 @@ export function syncSafeAreaInsets() {
root.style.setProperty('--app-safe-right', right)
root.style.setProperty('--app-safe-left', left)
// اگر env صفر بود ولی PWA standalone هستیم، حداقل فضای نوار وضعیت را جبران کن
const topPx = Number.parseFloat(top) || 0
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
// iOS Safari legacy
Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone)
if (isStandalone && topPx < 1) {
// Dynamic Island ≈ ۵۹px؛ بدون env حداقل این را جبران می‌کنیم
root.style.setProperty('--app-safe-top', '59px')
if (isStandaloneDisplay() && isAppleTouchDevice()) {
const next = (topPx < 1 ? MAX_SAFE_TOP_PX : topPx) + IOS_INSTALLED_TOP_BUFFER_PX
root.style.setProperty('--app-safe-top', `${next}px`)
}
}
+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 { getSettings } from '@/api/configuration'
import { useSettingsStore } from '@/store/settingsStore'
export const serviceFeePercentage = ref<number>(0.01)
export const serviceFeePercentage = ref<number>(0)
/** true = مبالغ رند: فروش → ریال دقیق (کارمزد از طلا)؛ خرید → وزن دقیق (کارمزد از ریال) */
export const feeDeductFromGold = ref(false)
@@ -9,6 +9,11 @@ export const feeDeductFromGold = ref(false)
/** آخرین فیلدی که کاربر مستقیم ویرایش کرده — مبنای به‌روزرسانی هنگام تغییر قیمت */
export type TradeInputLock = 'gram' | 'rial' | null
/** ریال قفل است؟ (کاربر مبلغ ریالی را مستقیم وارد کرده) → کارمزد از طلا */
export function isRialLocked(inputLock?: TradeInputLock): boolean {
return inputLock === 'rial'
}
/** حداکثر اعشار ذخیره/ارسال به سرور (مطابق orders.amount) */
export const TRADE_GRAM_STORAGE_DECIMALS = 10
@@ -44,13 +49,18 @@ export function normalizeServiceFeeRatio(value: number): number {
export async function loadServiceFeeFromSettings() {
try {
const settings = await getSettings()
if (settings && Array.isArray(settings)) {
const settingsStore = useSettingsStore()
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
const settings = settingsStore.settings
if (settings?.length) {
const feeSetting = settings.find(
(s) => s?.key === 'fee_service_charge_percentage' && s?.is_active === true
)
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(
@@ -215,3 +225,177 @@ export function calcSellGoldDebit(goldValue?: number | null, feeInGrams?: number
}
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 { useSettingsStore } from '@/store/settingsStore'
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
import { isLastPriceAbsenceSettled, lastPrice, lastPriceReady } from '@/utils/price'
export interface StoreTradingHours {
start: string
@@ -12,8 +12,24 @@ export interface EffectiveTradingHours {
start: string
end: string
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 productTradingHoursVersion = ref(0)
@@ -54,6 +70,11 @@ function hasProductTradingWindow(start?: string | null, end?: string | null): bo
return open !== close
}
/** بعد از merge زندهٔ تنظیمات ساعت کاری — computedهای صفحه اصلی دوباره حساب شوند */
export function touchStoreHoursSettings() {
productTradingHoursVersion.value += 1
}
export function setProductTradingHours(start?: string | null, end?: string | null) {
if (!hasProductTradingWindow(start, end)) {
productTradingHours.value = null
@@ -81,29 +102,77 @@ export function syncProductTradingHoursFromProducts(
setProductTradingHours(null, null)
}
export function getStoreTradingHours(): StoreTradingHours {
const settings = useSettingsStore()
const closeRaw = settings.getSettingByKey('close_price')
const closePriceEnabled =
closeRaw === true ||
closeRaw === 1 ||
String(closeRaw ?? 'false').trim().toLowerCase() === 'true'
function weekdayKey(now: Date): WeekdayKey {
return WEEKDAY_BY_JS[now.getDay()] ?? 'sat'
}
return {
start: normalizeTradingTimeSetting(settings.getSettingByKey('startDay'), '00:00'),
end: normalizeTradingTimeSetting(settings.getSettingByKey('endDay'), '23:59'),
closePriceEnabled,
function parseWeeklySchedule(raw: unknown): WeeklySchedule | null {
const text = String(raw ?? '').trim()
if (!text) return null
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
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) {
return {
start: settings.start,
end: settings.end,
enforced: true,
dayEnabled: true,
}
}
@@ -112,6 +181,7 @@ export function getEffectiveTradingHours(): EffectiveTradingHours {
start: productTradingHours.value.start,
end: productTradingHours.value.end,
enforced: true,
dayEnabled: true,
}
}
@@ -119,16 +189,40 @@ export function getEffectiveTradingHours(): EffectiveTradingHours {
start: settings.start,
end: settings.end,
enforced: false,
dayEnabled: true,
}
}
export function isStoreClosedBySchedule(now = new Date()): boolean {
const { start, end, enforced } = getEffectiveTradingHours()
if (!enforced) {
/** خارج از ساعت کاری سراسری — فقط وقتی close_price روشن باشد (صفحه اصلی) */
export function isOutsideGlobalStoreHours(now = new Date()): boolean {
const settings = getStoreTradingHours()
if (!settings.closePriceEnabled) {
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(
@@ -136,20 +230,14 @@ export function isOutsideStoreTradingHours(
start?: string,
end?: string,
): boolean {
const hours = start != null && end != null
? { start, end }
: getEffectiveTradingHours()
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
if (start != null && end != null) {
return isOutsideTimeWindow(now, start, end)
}
return isStoreClosedBySchedule(now)
}
export function hasLivePriceQuote(): boolean {
const p = lastPrice.value
return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0
return hasShowableHomePrice(lastPrice.value)
}
export function isStoreClosed(): boolean {
@@ -199,41 +287,81 @@ export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست'
/** سوییچ: خارج از ساعت کاری هم قیمت صفحه اصلی نشان داده شود */
export function shouldShowHomePriceWhenClosed(): boolean {
const settings = useSettingsStore()
return settings.isSettingTruthy('show_home_price_when_closed')
return useSettingsStore().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 → فروشگاه بسته است (ساعت کاری، سیگنال سرور، یا نبود مظنه)
* - open → نمایش عادی قیمت
*
* اگر show_home_price_when_closed روشن باشد و قیمت موجود باشد، به‌جای closed قیمت نشان داده می‌شود.
* وضعیت نمایش قیمت در صفحه اصلی — همان منطق بک‌اند:
* خارج از ساعت کاری (close_price روشن) → show_home_price_when_closed روشن: آخرین قیمت؛ خاموش: closed
* داخل ساعت کاری → مظنه زنده: open ؛ وگرنه فقط با show_last_price آخرین قیمت؛ وگرنه no_quote
*/
export function getPriceDisplayState(): PriceDisplayState {
void productTradingHoursVersion.value
void useSettingsStore().settingsRevision
const p = lastPrice.value
const showWhenClosed = shouldShowHomePriceWhenClosed()
if (p?.status === 'closed' || p?.closed === true) {
if (showWhenClosed && hasLivePriceQuote()) {
return 'open'
}
return 'closed'
const serverStatus = p?.status
// اگر عدد قیمت هست، no_quote لحظه‌ای سرور را جدی نگیر (race با تیک بعدی)
if (serverStatus === 'no_quote' && hasShowableHomePrice(p)) {
// fall through به منطق کلاینت
} else if (serverStatus === 'open' || serverStatus === 'closed' || serverStatus === 'no_quote') {
return serverStatus
}
if (isStoreClosed()) {
if (showWhenClosed && hasLivePriceQuote()) {
return 'open'
}
return 'closed'
if (isOutsideGlobalStoreHours()) {
if (!shouldShowHomePriceWhenClosed()) return 'closed'
return hasShowableHomePrice(p) ? 'open' : 'no_quote'
}
if (!hasLivePriceQuote()) {
return 'closed'
}
if (hasLiveQuote(p)) return 'open'
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 {
+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 { getSettings } from '@/api/configuration'
import type { DepositGuideVars } from '@/utils/depositGuideText'
import {
DEFAULT_DEPOSIT_GUIDE_TEXT,
@@ -32,6 +31,7 @@ export interface SupportMessengerSettings {
depositSupportMessageText?: string
depositAccountHolder?: string
depositCardNumber?: string
depositAccountNumber?: string
depositSheba?: string
depositSmsSupportNumber?: string
}
@@ -175,6 +175,7 @@ export function getDepositGuideVarsFromSupportSettings(
return {
accountHolder: settings.depositAccountHolder,
cardNumber: settings.depositCardNumber,
accountNumber: settings.depositAccountNumber,
sheba: settings.depositSheba,
customerName: getCustomerNameFromUser(),
}
@@ -367,10 +368,8 @@ export async function loadSupportMessengerFromSettings(forceSettingsRefresh = fa
await settingsStore.fetchSettings()
}
const settings = await getSettings()
if (!settings || !Array.isArray(settings)) return
settingsStore.mergeSettings(settings)
const settings = settingsStore.settings
if (!settings?.length) return
supportMessengerSettings.value = {
messengers: resolveSupportMessengersFromSettings(settings),
@@ -380,6 +379,7 @@ export async function loadSupportMessengerFromSettings(forceSettingsRefresh = fa
depositSupportMessageText: getSupportSettingValue(settings, 'deposit_support_message_text'),
depositAccountHolder: getSupportSettingValue(settings, 'deposit_account_holder'),
depositCardNumber: getSupportSettingValue(settings, 'deposit_card_number'),
depositAccountNumber: getSupportSettingValue(settings, 'deposit_account_number'),
depositSheba: getSupportSettingValue(settings, 'deposit_sheba'),
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 {get} from 'lodash'
import {message} from "ant-design-vue";
import {
hideServerUnavailableOverlay,
showServerUnavailableOverlay,
} from '@/utils/serverUnavailable'
interface Meta {
errorMessage?: 'message' | 'notification' | 'toast' | 'none'
@@ -71,6 +75,7 @@ function scheduleSessionVerify() {
meta: { errorMessage: 'none' },
})
} catch {
showPleaseLoginAgain()
await logoutToLogin()
} finally {
sessionVerifyInFlight = false
@@ -100,10 +105,7 @@ _axios.interceptors.request.use(
)
_axios.interceptors.response.use(
(response) => {
//console.log('this is response')
// 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)
hideServerUnavailableOverlay()
return response.data
},
async function (error) {
@@ -115,6 +117,7 @@ _axios.interceptors.response.use(
if (hadAuth) {
const path = requestPath(config)
if (path.includes('/customer/me')) {
showPleaseLoginAgain()
await logoutToLogin()
} else {
scheduleSessionVerify()
@@ -123,34 +126,68 @@ _axios.interceptors.response.use(
return Promise.reject(error)
}
if (response?.status === 503) {
showServerUnavailableOverlay(SERVER_ERROR)
return Promise.reject(error)
}
if (!shouldShowApiError(config)) {
return Promise.reject(error)
}
const apiMessage = extractApiErrorMessage(response?.data)
const status = Number(response?.status ?? 0)
if (response?.status === 403) {
showApiError(apiMessage || 'شما دسترسی لازم برای انجام این عملیات را ندارید', config)
showApiError(
withHttpStatus(apiMessage || 'شما دسترسی لازم برای انجام این عملیات را ندارید', 403),
config,
)
return Promise.reject(error)
}
if (response?.status === 422) {
if (apiMessage) {
showApiError(apiMessage, config)
showApiError(withHttpStatus(apiMessage, 422), config)
} else {
showApiError(withHttpStatus('خطا در اعتبارسنجی', 422), config)
}
return Promise.reject(error)
}
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)
}
showApiError(DEFAULT_LOAD_ERROR, config)
showApiError(NETWORK_ERROR, config)
return Promise.reject(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 {
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">
<div class="font-bold text-lg"></div>
<div class="w-full rounded-lg bg-gray-50 p-3">
<div class="flex justify-between ">
<div>وزن:</div>
<div class="flex items-center">
<div class="font-bold">{{ form.goldValue }}</div>
<div class="text-xs mr-1">گرم</div>
</div>
</div>
<div class="flex justify-between mt-3">
<div>مظنه:</div>
<div class="flex items-center">
<div class="font-bold">{{ usePriceWithoutType(Math.round(basePriceValue ?? 0)) }}</div>
<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">گرم</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>
<TradeFeeConfirmSummary
:is-buy="isBuy"
input-lock="gram"
:display-trade-gold-amount="form.goldValue ?? 0"
weight-unit="گرم"
:unit-price-rial="basePriceValue ?? unitPrice"
:fee-in-grams="feeInGrams"
:equivalent-price="equivalentPrice"
:payable-price="payablePrice"
:order-gold-amount="form.goldValue ?? 0"
:fee-in-rial="feeInRial"
:sell-gold-debit="sellGoldDebit"
/>
<BasicButton class="mb-20 mt-5 w-full" type="primary" :disabled="loading" @click="ok">ثبت نهایی</BasicButton>
</BasicActionSheet>
<van-dialog v-model:show="noCompleteVisible" class="completeButtonStyle" confirm-button-text="تکمیل اطلاعات"
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 { useUserStore } from '@/store/userStore'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import TradeFeeConfirmSummary from '@/components/TradeFeeConfirmSummary.vue'
import { justNumber } from '@/utils/inputRules'
import GoldSkeleton from '@/views/base/gold/components/GoldSkeleton.vue'
import GoldLoading from '@/components/GoldLoading.vue'
@@ -65,10 +65,12 @@ export function useEducatePage() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updateMe(userFormState.value)
const res: any = await updateMe(userFormState.value)
await userStore.getMyInfo()
editPassVisible.value = false
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
message.success(
res?.message || 'اطلاعات شما با موفقیت به روز رسانی شد',
)
} catch (e) {
console.log(e)
} finally {
@@ -10,7 +10,7 @@
}
.gold-embed {
max-height: min(70vh, 35rem);
max-height: min(78vh, 40rem);
overflow-x: hidden;
overflow-y: auto;
width: 100%;
+57 -108
View File
@@ -18,33 +18,45 @@
<div :class="panelClass">
<div
: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' : '',
]"
>
<!-- <div @click="isBuy = false" class="w-full text-center" :class="!isBuy?'text-primary':'text-gray-400'">خرید طلا</div>
<div @click="isBuy = true" class="w-full text-center" :class="isBuy?'text-primary':'text-gray-400'">فروش طلا</div>-->
<div class="flex justify-evenly w-full px-2 h-11 items-center bg-gray-50 relative rounded ">
<div :class="isBuy?'text-tertiary':'text-gray-400'" class="w-full text-center z-2"
@click.stop="setIsBuy(true) ">
خرید {{ tradeProductLabel }}
<div class="flex w-full p-1 items-center border border-gray-200 rounded-full bg-white gap-1">
<div
class="w-full h-10 flex items-center justify-center font-bold rounded-full transition-all"
:class="isBuy ? 'bg-green-600 text-white shadow-sm' : 'text-green-600'"
@click.stop="setIsBuy(true)"
:style="!isProductBuyOpen(props?.ProductItem) ? 'pointer-events: none; opacity: 0.35;' : ''"
>
خرید
</div>
<div :class="!isBuy?'text-tertiary':'text-gray-400'" class="w-full text-center z-2"
@click.stop="setIsBuy(false) ">
فروش {{ tradeProductLabel }}
<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="!isProductSellOpen(props?.ProductItem) ? 'pointer-events: none; opacity: 0.35;' : ''"
>
فروش
</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 v-if="userStore" :class="contentClass">
<a-spin :spinning="!price">
<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">
<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 :class="!isBuy? 'text-red-700' : 'text-green-700'" class="font-bold text-2xl color-transition">
{{ usePriceWithoutType(goldPriceValue) }}
@@ -58,7 +70,7 @@
</div>
</a-spin>
<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 />
<div class="z-100 relative">
<a-form-item>
@@ -137,7 +149,7 @@
موجودی کیف پول ناکافی می باشد.
</div>
</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>کارمزد:</div>
<div class="flex gap-2 items-center">
@@ -162,51 +174,19 @@
<BasicActionSheet v-if="!isStandaloneDesktop" v-model:show="confirmAction">
<div class="font-bold text-lg"></div>
<div class="w-full rounded-lg bg-gray-50 p-3">
<div class="flex justify-between ">
<div>وزن:</div>
<div class="flex items-center">
<div class="font-bold">{{ displayTradeGoldAmount }}</div>
<div class="text-xs mr-1">{{ weightUnit }}</div>
</div>
</div>
<div class="flex justify-between mt-3">
<div>مظنه:</div>
<div class="flex items-center">
<div class="font-bold">{{ usePriceWithoutType(Math.round(goldPriceValue)) }}</div>
<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>
<TradeFeeConfirmSummary
:is-buy="isBuy"
:input-lock="inputLock"
:display-trade-gold-amount="displayTradeGoldAmount"
:weight-unit="weightUnit"
:unit-price-rial="goldPriceValue"
:fee-in-grams="feeInGrams"
:equivalent-price="equivalentPrice"
:payable-price="payablePrice"
:order-gold-amount="orderGoldAmount"
:fee-in-rial="feeInRial"
:sell-gold-debit="sellGoldDebit"
/>
<BasicButton
class="mb-20 mt-5 w-full"
type="primary"
@@ -224,51 +204,19 @@
destroy-on-close
custom-footer
>
<div class="w-full rounded-lg bg-gray-50 p-3">
<div class="flex justify-between ">
<div>وزن:</div>
<div class="flex items-center">
<div class="font-bold">{{ displayTradeGoldAmount }}</div>
<div class="text-xs mr-1">{{ weightUnit }}</div>
</div>
</div>
<div class="flex justify-between mt-3">
<div>مظنه:</div>
<div class="flex items-center">
<div class="font-bold">{{ usePriceWithoutType(Math.round(goldPriceValue)) }}</div>
<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>
<TradeFeeConfirmSummary
:is-buy="isBuy"
:input-lock="inputLock"
:display-trade-gold-amount="displayTradeGoldAmount"
:weight-unit="weightUnit"
:unit-price-rial="goldPriceValue"
:fee-in-grams="feeInGrams"
:equivalent-price="equivalentPrice"
:payable-price="payablePrice"
:order-gold-amount="orderGoldAmount"
:fee-in-rial="feeInRial"
:sell-gold-debit="sellGoldDebit"
/>
<BasicButton class="mt-5 w-full" type="primary" :disabled="loading" @click="ok">
ثبت نهایی
</BasicButton>
@@ -311,6 +259,7 @@ import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import TradeFeeConfirmSummary from '@/components/TradeFeeConfirmSummary.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
import { justNumber } from '@/utils/inputRules'
@@ -398,7 +347,7 @@ const contentClass = computed(() => {
if (isStandaloneMobile.value) {
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 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="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__icon-wrap">
<Icon
@@ -57,12 +68,14 @@ import {
STORE_CLOSED_TITLE,
getPriceDisplayState,
getStoreClosedHint,
isHomeQuotePending,
} from '@/utils/storeHours'
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
const userStore = useUserStore()
const displayState = computed(() => getPriceDisplayState())
const quotePending = computed(() => isHomeQuotePending())
const statusTitle = computed(() =>
displayState.value === 'closed' ? STORE_CLOSED_TITLE : NO_QUOTE_MESSAGE
@@ -76,6 +89,9 @@ const statusHint = computed(() => {
})
const containerClass = computed(() => {
if (quotePending.value) {
return 'boursecu-price-box boursecu-price-box--open'
}
if (displayState.value !== 'open') {
return 'boursecu-price-box boursecu-price-box--closed'
}
@@ -129,6 +145,10 @@ void price
font-weight: 500;
}
.boursecu-price-skeleton {
background: rgba(255, 255, 255, 0.25);
}
.status-box {
display: flex;
flex-direction: column;
@@ -5,7 +5,7 @@
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/129cf3a1-df84-4758-a856-58c133ae6013/R5RqclXA8G.json"
v-bind="lottieBind"
/>
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
</div>
@@ -23,7 +23,8 @@
<script setup lang="ts">
import DialogVant from '@/components/DialogVant.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<{
show: boolean
@@ -34,8 +35,29 @@ const emit = defineEmits<{
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({
get: () => props.show,
set: (val) => emit('update:show', val)
})
</script>
</script>
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { getConstByKey } from '@/api/const'
import { useSettingsStore } from '@/store/settingsStore'
import { normalizePublicFileUrl } from '@/utils/file'
function parseJsonArray(raw: unknown): unknown[] {
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
} catch (error) {
console.error('Error loading banners:', error)
@@ -2,11 +2,22 @@
<div
class="dhome-surface dhome-hero-price"
:class="{
'dhome-hero-price--boursecu-open': displayState === 'open',
'dhome-hero-price--boursecu-closed': displayState !== 'open',
'dhome-hero-price--boursecu-open': displayState === 'open' || quotePending,
'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-icon">
<BasicIcon
@@ -56,12 +67,14 @@ import {
STORE_CLOSED_TITLE,
getPriceDisplayState,
getStoreClosedHint,
isHomeQuotePending,
} from '@/utils/storeHours'
import { formatSignedPercentChange, usePriceWithoutType } from '@/utils/usePrice'
const userStore = useUserStore()
const displayState = computed(() => getPriceDisplayState())
const quotePending = computed(() => isHomeQuotePending())
const statusTitle = computed(() =>
displayState.value === 'closed' ? STORE_CLOSED_TITLE : NO_QUOTE_MESSAGE
@@ -110,6 +123,10 @@ const percentChange = computed(() => {
color: rgba(255, 255, 255, 0.85);
}
.boursecu-price-skeleton {
background: rgba(255, 255, 255, 0.25);
}
.dhome-hero-price__closed {
display: flex;
flex-direction: column;
@@ -36,7 +36,13 @@ const resolvedActions = computed(() => {
})
function handleClick(action: HomeQuickActionItem) {
router.push(action.route)
if (action.handler) {
action.handler()
return
}
if (action.route) {
router.push(action.route)
}
}
</script>
@@ -38,7 +38,13 @@ const resolvedActions = computed(() => {
})
function navigate(action: HomeQuickActionItem) {
router.push(action.route)
if (action.handler) {
action.handler()
return
}
if (action.route) {
router.push(action.route)
}
}
</script>
+2 -2
View File
@@ -33,7 +33,7 @@
class="w-5rem h-5rem mt-[-1.6rem] ml-[-2rem]"
autoplay
loop
src="https://lottie.host/3d46ebb3-a580-41eb-ade7-b8829c32b071/PJPuSKbpze.json"
src="/lottie/goldBars.json"
/>
</div>
</div>
@@ -315,7 +315,7 @@
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/129cf3a1-df84-4758-a856-58c133ae6013/R5RqclXA8G.json"
src="/lottie/logoutConfirm.json"
/>
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
</div>
@@ -1,4 +1,5 @@
import { getBoursecuActionIconSrc } from '@/utils/config'
import { openGoldDayCalculator } from '@/composables/useGoldDayCalculator'
import type { HomeQuickActionItem } from '../types'
export const boursecuQuickActions: HomeQuickActionItem[] = [
@@ -44,4 +45,10 @@ export const boursecuQuickActions: HomeQuickActionItem[] = [
iconSrc: getBoursecuActionIconSrc('convert'),
route: { name: 'buyProduct' },
},
{
id: 'calculator',
label: 'محاسبه‌گر',
icon: 'vuesax-linear:chart-2',
handler: () => openGoldDayCalculator(),
},
]
@@ -35,7 +35,13 @@ const resolvedActions = computed(() => {
})
function handleClick(action: HomeQuickActionItem) {
router.push(action.route)
if (action.handler) {
action.handler()
return
}
if (action.route) {
router.push(action.route)
}
}
</script>
+2 -1
View File
@@ -15,7 +15,8 @@ export interface HomeQuickActionItem {
label: string
icon: string
iconSrc?: string
route: { name: string; query?: Record<string, string> }
route?: { name: string; query?: Record<string, string> }
handler?: () => void
}
export interface HomeLayoutSectionConfig {
@@ -70,7 +70,7 @@ import {
isCountBasedProduct,
} from '@/utils/productDealMethod'
import { formatGoldAmount, usePriceWithoutType } from '@/utils/usePrice'
import { persianToEnglishDigits } from '@/utils/persianDigits'
import { normalizeDecimalInput } from '@/utils/persianDigits'
interface Props {
item: CountPInventory
@@ -121,7 +121,9 @@ function selectCountInput(e: FocusEvent) {
function onCountInput(raw: string | number) {
countError.value = ''
const normalized = persianToEnglishDigits(String(raw ?? '').trim())
const normalized = normalizeDecimalInput(String(raw ?? '').trim(), {
commaAsThousand: isCountBased.value,
})
if (!normalized) {
props.item.count = 0 as any
return
@@ -19,6 +19,7 @@ usePhysicalReceiveList()
</script>
<style scoped>
/* Mobile shell: fill viewport and scroll inside the list view */
.physical-receive-list-root {
flex: 1 1 0;
min-height: 0;
@@ -34,19 +35,19 @@ usePhysicalReceiveList()
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 {
flex: none;
display: block;
display: flex;
flex-direction: column;
flex: 1 1 auto;
height: auto;
min-height: 0;
overflow: visible;
}
.physical-receive-list-root--desktop > * {
flex: none;
flex: 1 1 auto;
height: auto;
min-height: 0;
overflow: visible;
}
</style>
@@ -2,7 +2,7 @@
.dpr {
min-height: 100%;
height: auto;
height: 100%;
background: #f6f7f9;
color: #111827;
overflow: visible;
@@ -12,6 +12,8 @@
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
min-height: 100%;
box-sizing: border-box;
overflow: visible;
}
+27 -6
View File
@@ -51,17 +51,38 @@ const { isDesktop } = usePhysicalReceiveShell()
}
.physical-receive-page-root--desktop {
display: block;
height: auto;
height: 100%;
min-height: 100%;
overflow: visible;
display: flex;
flex-direction: column;
}
.physical-receive-page-root--desktop :deep(.physical-receive-list-root),
.physical-receive-page-root--desktop :deep(.dpr-list) {
height: auto;
.physical-receive-page-root--desktop > .dpr {
flex: 1 1 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;
overflow: visible;
}
.physical-receive-page-root--desktop :deep(.physical-receive-list-root--desktop > .dpr-list) {
flex: 1 1 auto;
}
</style>
+10 -19
View File
@@ -4,12 +4,12 @@
<div class="dptr-card__meta">
<div class="dptr-card__row">
<span class="dptr-card__label">جهت قیمت:</span>
<span class="dptr-card__value">{{ returnPriceCondition(item.price_condition) }}</span>
<span class="dptr-card__label">محصول:</span>
<span class="dptr-card__value">{{ productTitle }}</span>
</div>
<div class="dptr-card__row">
<span class="dptr-card__label">نوع هشدار:</span>
<span class="dptr-card__value">{{ returnAction(item.action) }}</span>
<span class="dptr-card__label">جهت قیمت:</span>
<span class="dptr-card__value">{{ returnPriceCondition(item.price_condition) }}</span>
</div>
</div>
@@ -76,8 +76,8 @@
</div>
</div>
<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="returnAction(item.action)" />
</div>
<a-divider class="!my-3" />
<div class="flex justify-between mt-1">
@@ -108,7 +108,7 @@
<script lang="ts" setup>
import { usePrice } from '../../../../utils/usePrice'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { toJalali } from '../../../../utils/useDateTime'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import type { PriceTrigger } from '@/models/PriceTrigger'
@@ -132,14 +132,16 @@ const props = withDefaults(
const emits = defineEmits(['edit', 'refresh'])
const productTitle = computed(() => props.item.product?.title || 'طلا')
onMounted(() => {
isActive.value = props.item.is_active
})
function changeActive(val: boolean) {
async function changeActive(val: boolean) {
try {
loading.value = true
updatePriceTrigger(props?.item.id, { is_active: val })
await updatePriceTrigger(props?.item.id, { is_active: val })
} catch (e) {
console.log(e)
isActive.value = !isActive.value
@@ -171,15 +173,4 @@ async function deleteItem() {
deleteLoading.value = false
}
}
function returnAction(val: string) {
switch (val) {
case 'sms':
return 'پیامک'
case 'email':
return 'ایمیل'
case 'notify':
return 'نوتیفیکیشن'
}
}
</script>
@@ -7,6 +7,16 @@
@finish="ok()"
>
<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">
<BasicSelect
v-model:value="form.price_condition"
@@ -27,12 +37,7 @@
type="price"
/>
</a-form-item>
<a-form-item :rules="[required()]" label="نوع هشدار را انتخاب کنید" name="action">
<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>
<p class="m-0 text-sm text-gray-500">پس از فعال شدن هشدار، پیامک به شماره موبایل شما ارسال می‌شود.</p>
<a-form-item>
<a-spin :spinning="loading">
<BasicButton class="w-full" html-type="submit" type="primary">
@@ -56,8 +61,8 @@ const {
formRef,
editId,
loading,
radioStyle,
typeItems,
products,
ok,
} = usePriceTriggerPageContext()
</script>
@@ -2,12 +2,25 @@ import { inject, onMounted, provide, reactive, ref, type InjectionKey, type Ref
import { cloneDeep } from 'lodash'
import { type FormInstance, message } from 'ant-design-vue'
import { createPriceTrigger, getPriceTriggers, updatePriceTrigger } from '@/api/triggerPrice'
import { getProducts } from '@/api/products'
import type { SelectItem } from '@/models/SelectItem'
import type { PriceTrigger } from '@/models/PriceTrigger'
import type { Product } from '@/models/products'
import { loading } from '@/utils'
function emptyForm(): PriceTrigger {
return {
product_id: undefined,
price_condition: '',
is_active: true,
action: 'sms',
target_price: '',
}
}
export interface PriceTriggerPageContext {
items: Ref<PriceTrigger[]>
products: Ref<Product[]>
addTriggerVisible: Ref<boolean>
editId: Ref<number | undefined>
form: Ref<PriceTrigger>
@@ -27,6 +40,7 @@ export const PRICE_TRIGGER_PAGE_CONTEXT_KEY: InjectionKey<PriceTriggerPageContex
export function usePriceTriggerPage() {
const items = ref<PriceTrigger[]>([])
const products = ref<Product[]>([])
const addTriggerVisible = ref(false)
const editId = ref<number | undefined>(undefined)
const formRef = ref<FormInstance>()
@@ -37,12 +51,7 @@ export function usePriceTriggerPage() {
lineHeight: '30px',
})
const form = ref<PriceTrigger>({
price_condition: '',
is_active: true,
action: '',
target_price: '',
})
const form = ref<PriceTrigger>(emptyForm())
const typeItems: SelectItem[] = [
{ value: 'بزرگتر', key: '>' },
@@ -51,12 +60,7 @@ export function usePriceTriggerPage() {
]
function resetFields() {
form.value = {
price_condition: '',
is_active: true,
action: '',
target_price: '',
}
form.value = emptyForm()
}
function openAdd() {
@@ -66,7 +70,10 @@ export function usePriceTriggerPage() {
}
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
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() {
await formRef.value?.validateFields().then(async () => {
try {
loading.value = true
form.value.action = 'sms'
if (editId.value) {
await updatePriceTrigger(editId.value, form.value)
} else {
@@ -104,10 +120,12 @@ export function usePriceTriggerPage() {
onMounted(() => {
initItems()
loadProducts()
})
const context: PriceTriggerPageContext = {
items,
products,
addTriggerVisible,
editId,
form,
@@ -1,30 +1,30 @@
<template>
<div class="h-35rem overflow-y-auto">
<div class=" !overflow-y-scroll bg-white py-3 text-primary font-bold flex justify-evenly">
<!-- <div @click="isBuy = false" class="w-full text-center" :class="!isBuy?'text-primary':'text-gray-400'">خرید طلا</div>
<div @click="isBuy = true" class="w-full text-center" :class="isBuy?'text-primary':'text-gray-400'">فروش طلا</div>-->
<div class="flex justify-evenly w-full px-2 h-11 items-center bg-gray-50 relative rounded ">
<div :class="isBuy?'text-secondary':'text-gray-400'" class="w-full text-center z-2"
@click.stop="setIsBuy(true) "
:style="!props?.ProductItem?.open_buy ? 'pointer-events: none; opacity: 0.5;' : ''">
خرید {{ 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;' : ''"
<div class="h-[40rem] overflow-y-auto">
<div class="bg-white px-3 pt-3 pb-1">
<div class="flex w-full p-1 items-center border border-gray-200 rounded-full bg-white gap-1">
<div
class="w-full h-10 flex items-center justify-center font-bold rounded-full transition-all"
:class="isBuy ? 'bg-green-600 text-white shadow-sm' : 'text-green-600'"
@click.stop="setIsBuy(true)"
:style="!props?.ProductItem?.open_buy ? 'pointer-events: none; opacity: 0.35;' : ''"
>
فروش {{ 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 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 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">-->
<div
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="[
!isBuy ? 'bg-red-100' : 'bg-green-100',
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">
<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 :class="!isBuy? 'text-red-700' : 'text-green-700'" class="font-bold text-2xl color-transition">
{{ isBuy ? usePriceWithoutType(buyTotalPrice) : usePriceWithoutType(sellTotalPrice) }}
@@ -56,7 +65,7 @@
</div>
<!-- </a-spin>-->
<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 />
<div class="z-100 relative">
<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 BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
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 GoldLoading from '@/components/GoldLoading.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
@@ -710,9 +719,6 @@ watch(
}
)
// تابع تبدیل اعداد فارسی به انگلیسی
const persianToEnglish = persianToEnglishDigits
watch(() => form.value.goldValue, (newVal) => {
if (newVal !== undefined && newVal !== null && newVal !== '') {
calculateCash(newVal)
@@ -759,10 +765,7 @@ function handleGoldValueChange(val: string | number | undefined) {
form.value.goldValue = undefined
return
}
// تبدیل اعداد فارسی به انگلیسی
const valStr = val.toString()
const convertedValue = persianToEnglish(valStr)
const numericValue = parseFloat(convertedValue)
const numericValue = parseDecimalInput(val, { commaAsThousand: false })
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[]) {
if (products.length) {
productsCloseTime.value = products[0].auto_close_at
@@ -141,8 +187,12 @@ export function applyProductsListFromSocket(products: Product[]) {
return
}
getProductsCatalogRef().value = products
applyProductsList(products)
const merged = mergeLiveProductList(
products,
getProductsCatalogRef().value.length ? getProductsCatalogRef().value : productsFirstItems.value,
)
getProductsCatalogRef().value = merged
applyProductsList(merged)
}
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__value' : ''">{{ item.eaj }}</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>
</template>
@@ -17,7 +17,7 @@ import {
let activeFetchId = 0
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 physicalReceiveItems = ref<Physicalreceive[]>([])
export const transactionTableOutput = ref<TableEngineOutput<Transaction | Physicalreceive> | null>(
@@ -151,7 +151,7 @@ export function resetTransactionsPageData() {
loading.value = false
loadingMore.value = false
transactionsBoursecuMode.value = true
transactionPagination.value = { filters: [], limit: 7, page: 0 }
transactionPagination.value = { filters: [], limit: 10, page: 0 }
transactionItems.value = []
physicalReceiveItems.value = []
transactionTableOutput.value = null
@@ -191,7 +191,7 @@ export function buildBoursecuTransactionPagination(
return {
page: 0,
limit: 7,
limit: 10,
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 { useSettingsStore } from '@/store/settingsStore'
import { useSupportMessenger } from '@/composables/useSupportMessenger'
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
import { openGoldDayCalculator } from '@/composables/useGoldDayCalculator'
import { isUserSupportChatEnabled, unreadSupportCount } from '@/utils/chat'
import { resetHomePageData } from '../../home/composables/useHomePageData'
import { resetWalletPageData } from '../../wallet/composables/useWalletPageData'
@@ -87,6 +89,7 @@ export function useUserPage() {
const showLogoutConfirm = ref(false)
const localAvatarUrl = ref<string | null>(null)
const { openSupportMessenger } = useSupportMessenger()
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
const profileAvatarUrl = computed(
() => localAvatarUrl.value || getUserAvatarUrl(userStore.userInfo),
@@ -124,6 +127,13 @@ export function useUserPage() {
onClick: () => { editPasswordVisible.value = true },
visible: true,
},
{
id: 'goldDayCalculator',
label: 'قیمت روز طلا',
icon: 'vuesax-linear:chart-2',
onClick: () => openGoldDayCalculator(),
visible: true,
},
{
id: 'address',
label: 'آدرس ها',
@@ -131,13 +141,6 @@ export function useUserPage() {
onClick: () => router.push({ name: 'address' }),
visible: true,
},
{
id: 'notify',
label: 'هشدار قیمت',
icon: 'vuesax-linear:bill',
onClick: () => router.push({ name: 'notifyTrigger' }),
visible: settingsStore.isSettingTruthy('has_page_price_trigger'),
},
{
id: 'piggy',
label: 'قلک‌ها',
@@ -170,6 +173,20 @@ export function useUserPage() {
onClick: () => router.push({ name: 'physicalReceive' }),
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',
label: 'قوانین و مقررات',
@@ -227,11 +244,13 @@ export function useUserPage() {
try {
loading.value = true
normalizeAuthFormFields(userFormState.value)
await updateMe(userFormState.value)
const res: any = await updateMe(userFormState.value)
await userStore.getMyInfo()
localAvatarUrl.value = getUserAvatarUrl(userStore.userInfo)
editPassVisible.value = false
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
message.success(
res?.message || 'اطلاعات شما با موفقیت به روز رسانی شد',
)
} catch (e) {
console.log(e)
} finally {
@@ -18,12 +18,11 @@
</div>
<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>
<button
type="button"
class="deposit-guide__account-value"
:class="{ 'deposit-guide__account-value--empty': !hasDepositCardValue }"
:aria-label="`کپی ${depositCardLabel}`"
@click="copyDepositCardNumber"
>
@@ -33,17 +32,33 @@
size="18"
color="#15803d"
class="deposit-guide__copy-icon"
:class="{ 'deposit-guide__copy-icon--disabled': !hasDepositCardValue }"
/>
</button>
</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>
<button
type="button"
class="deposit-guide__account-value"
:class="{ 'deposit-guide__account-value--empty': !hasDepositShebaValue }"
:aria-label="`کپی ${depositShebaLabel}`"
@click="copyDepositShebaNumber"
>
@@ -53,7 +68,6 @@
size="18"
color="#15803d"
class="deposit-guide__copy-icon"
:class="{ 'deposit-guide__copy-icon--disabled': !hasDepositShebaValue }"
/>
</button>
</div>
@@ -119,12 +133,16 @@ const props = withDefaults(
const {
depositCardNumber,
depositAccountNumber,
depositSheba,
depositCardLabel,
depositAccountNumberLabel,
depositShebaLabel,
hasDepositCardValue,
hasDepositAccountNumberValue,
hasDepositShebaValue,
copyDepositCardNumber,
copyDepositAccountNumber,
copyDepositShebaNumber,
hasMessengerLink,
openDepositSupportMessenger,
@@ -146,7 +146,7 @@ export function useWalletPage() {
async function addCard() {
try {
loading.value = true
await createCard(cardForm.value)
const res: any = await createCard(cardForm.value)
await getCards()
showAddCard.value = false
cardForm.value = {
@@ -156,7 +156,9 @@ export function useWalletPage() {
cart_number: '',
shaba_number: '',
}
message.success('کارت وارد شده با موفقیت افزوده شد و پس از تایید نمایش داده خواهد شد.')
message.success(
res?.message || 'اطلاعات کارت بانکی شما ثبت شد و پس از تایید ادمین فعال سازی خواهد شد',
)
} catch (e) {
console.log(e)
} finally {

Some files were not shown because too many files have changed in this diff Show More