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
@@ -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 {