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 {
+13 -2
View File
@@ -181,10 +181,14 @@
ورود با اثر انگشت
</BasicButton>
<button type="button" class="dauth-link-btn dauth-link-btn--primary" @click="goToAboutSoftware">
درباره نرم‌افزار
</button>
<div v-if="!isTWA()" class="mt-1 flex flex-col gap-2">
<a
v-if="!!setting.getSettingByKey('apk_download_link')"
class="dauth-link-btn dauth-link-btn--primary !hover:text-primary "
class="dauth-link-btn dauth-link-btn--primary"
:href="setting.getSettingByKey('apk_download_link')"
>
دانلود اپ اندروید
@@ -232,7 +236,7 @@
<div v-if="!isTWA()" class="mt-1 flex flex-col gap-2">
<a
v-if="!!setting.getSettingByKey('apk_download_link')"
class="dauth-link-btn dauth-link-btn--primary !hover:text-primary "
class="dauth-link-btn dauth-link-btn--primary"
:href="setting.getSettingByKey('apk_download_link')"
>
دانلود اپ اندروید
@@ -241,6 +245,9 @@
<div>نصب نسخه وب اپ</div>
</BasicButton>
</div>
<BasicButton class="mt-2 w-full" @click="goToAboutSoftware">
<div>درباره نرم‌افزار</div>
</BasicButton>
<div
v-if="isRegister"
class="cursor-pointer flex gap-1 justify-center mt-5 text-xs "
@@ -461,6 +468,10 @@ async function goToAboutUs(){
await router.push('/aboutUs')
}
async function goToAboutSoftware() {
await router.push({ name: 'aboutSoftware' })
}
const { tryInstallOrOpenGuide: installWebApp } = usePwaInstall()
</script>
+20 -2
View File
@@ -77,6 +77,7 @@
/>
</a-form-item>
<a-form-item
v-if="settingsStore.isReferralFieldVisible"
:rules="referralIdRules"
:class="variant === 'desktop' ? '' : 'home_appear_fade_in_down'"
label="کد معرف"
@@ -99,6 +100,9 @@
<a-spin v-if="loading" size="small" />
<span v-else>ثبت نام</span>
</BasicButton>
<button type="button" class="dauth-link-btn dauth-link-btn--primary" @click="goToAboutSoftware">
درباره نرم‌افزار
</button>
</div>
<div v-if="variant === 'desktop'" class="dauth-switch">
@@ -120,6 +124,9 @@
<div v-else>ثبت نام</div>
</div>
</BasicButton>
<BasicButton class="mt-2 w-full" @click="goToAboutSoftware">
<div>درباره نرم‌افزار</div>
</BasicButton>
<div
class="cursor-pointer flex gap-1 justify-center mt-5 text-xs"
@click="$emit('register', false)"
@@ -251,7 +258,10 @@ async function handleOnComplete(value: string) {
userFormState.token = value
registerAccount()
}*/
onMounted(() => {
onMounted(async () => {
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (router.currentRoute.value.query.id){
userFormState.referral_id = router.currentRoute.value.query.id
disableForm.value=true
@@ -267,7 +277,11 @@ function registerAccount() {
// if (!stepTwoVerify.value) {
URLUserType() === 'single' ? userFormState.type = 2 : userFormState.type = 1
// console.log(userFormState)
if (!userFormState.referral_id || userFormState.referral_id === '') delete userFormState.referral_id
if (!settingsStore.isReferralFieldVisible && !disableForm.value) {
delete userFormState.referral_id
} else if (!userFormState.referral_id || userFormState.referral_id === '') {
delete userFormState.referral_id
}
normalizeAuthFormFields(userFormState)
const { id } = await registerCustomer(userFormState)
registeredInfo.value = true
@@ -297,6 +311,10 @@ async function goToAboutUs(){
await router.push('/aboutUs')
}
async function goToAboutSoftware() {
await router.push({ name: 'aboutSoftware' })
}
const goToEducate = () => {
router.push({ name: "educate" })
}
+3 -3
View File
@@ -16,7 +16,7 @@ import { useWalletStore } from '@/store/walletStore'
import { useSettingsStore } from '@/store/settingsStore'
import { setTruth } from '@/app/authBoot'
import { completeLoginNavigation } from '@/layouts/composables/useMobileScrollReset'
import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { normalizeAuthFormFields, persianToEnglishDigits } from '@/utils/persianDigits'
import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
import { usePwaInstall } from '@/composables/usePwaInstall'
import {
@@ -79,12 +79,12 @@ export function useLoginForm() {
}
function handleOnChange(value: string) {
userFormState.token = value
userFormState.token = persianToEnglishDigits(String(value ?? ''))
normalizeAuthFormFields(userFormState)
}
async function handleOnComplete(value: string) {
userFormState.token = value
userFormState.token = persianToEnglishDigits(String(value ?? ''))
normalizeAuthFormFields(userFormState)
phoneVerification()
}
+10 -2
View File
@@ -6,10 +6,12 @@ import { loading, URLUserType } from '@/utils'
import { registerCustomer } from '@/api/login'
import router from '@/router'
import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { useSettingsStore } from '@/store/settingsStore'
export function useRegisterForm() {
const registeredInfo = ref(false)
const disableForm = ref(false)
const settingsStore = useSettingsStore()
const userFormState = reactive<User>({
access_token: '',
@@ -17,7 +19,10 @@ export function useRegisterForm() {
const formRef = ref<FormInstance>()
onMounted(() => {
onMounted(async () => {
if (!settingsStore.loaded) {
await settingsStore.fetchSettings()
}
if (router.currentRoute.value.query.id) {
userFormState.referral_id = router.currentRoute.value.query.id as string
disableForm.value = true
@@ -29,7 +34,10 @@ export function useRegisterForm() {
try {
loading.value = true
URLUserType() === 'single' ? (userFormState.type = 2) : (userFormState.type = 1)
if (!userFormState.referral_id || userFormState.referral_id === '') {
// فیلد مخفی + بدون لینک دعوت → کد را نفرست
if (!settingsStore.isReferralFieldVisible && !disableForm.value) {
delete userFormState.referral_id
} else if (!userFormState.referral_id || userFormState.referral_id === '') {
delete userFormState.referral_id
}
normalizeAuthFormFields(userFormState)
@@ -44,6 +44,10 @@ function goToEducate() {
function goToAboutUs() {
router.push('/aboutUs')
}
function goToAboutSoftware() {
router.push({ name: 'aboutSoftware' })
}
</script>
<template>
@@ -162,11 +166,14 @@ function goToAboutUs() {
>
ورود با اثر انگشت
</BasicButton>
<button type="button" class="dauth-link-btn" @click="goToAboutSoftware">
درباره نرم‌افزار
</button>
<div v-if="!isTWA()" class="dauth-actions-row flex flex-col gap-2">
<a
v-if="!!setting.getSettingByKey('apk_download_link')"
class="dauth-link-btn dauth-link-btn--primary !hover:text-primary "
class="dauth-link-btn dauth-link-btn--primary"
:href="setting.getSettingByKey('apk_download_link')"
>
دانلود اپ اندروید
@@ -40,6 +40,10 @@ function goToAboutUs() {
router.push('/aboutUs')
}
function goToAboutSoftware() {
router.push({ name: 'aboutSoftware' })
}
function handleSuccessDismiss() {
dismissSuccess()
emit('register', false)
@@ -97,6 +101,7 @@ function handleSuccessDismiss() {
</a-form-item>
<a-form-item
v-if="settingsStore.isReferralFieldVisible"
:rules="referralIdRules"
label="کد معرف"
name="referral_id"
@@ -114,6 +119,10 @@ function handleSuccessDismiss() {
<a-spin v-if="loading" size="small" />
<span v-else>ثبت نام</span>
</BasicButton>
<button type="button" class="dauth-link-btn" @click="goToAboutSoftware">
درباره نرم‌افزار
</button>
</div>
<div class="dauth-switch">
@@ -0,0 +1,315 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useFullscreen } from '@vueuse/core'
import type { Product } from '@/models/products'
import { getTvProducts } from '@/api/products'
import { getConfigApp } from '@/utils/config'
import { getProductBuyPrice, getProductSellPrice, isProductPriceInactive } from '@/utils/productPrice'
import { getLastPrices, getTradeBuyPrice, getTradeSellPrice, lastPrice, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import { getPriceDisplayMessage, getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
import { isMeltedTradeGroup } from '@/utils/productCategories'
import { priceSocketConnected, priceStreamLifecycle } from '@/utils/priceSocketState'
import { usePrice } from '@/utils/usePrice'
import logoFallback from '@/assets/img/boursemes_logofull.png'
//@ts-ignore
import moment from 'moment-jalaali'
//@ts-ignore
import fa from 'moment/src/locale/fa'
moment.locale('fa', fa)
moment.loadPersian({ usePersianDigits: false })
const CURSOR_HIDE_MS = 2500
const TV_HTML_CLASS = 'app-layout--tv'
const REFRESH_MS = 15000
function productText(product: Product) {
return `${product.group_type ?? ''} ${product.title ?? ''}`
}
function isShowOnTv(product: Product) {
const value = product.show_on_tv as unknown
return value === true || value === 1 || value === '1' || value === 'true'
}
function isParsianCoin(product: Product) {
return productText(product).includes('پارسیان')
}
function isBankCoin(product: Product) {
if (isParsianCoin(product)) return false
const text = productText(product)
const group = (product.group_type ?? '').trim()
if (group === 'سکه' || group === 'سکه بانکی' || group.includes('بانکی')) return true
return text.includes('سکه') && /تمام|نیم|ربع/.test(text)
}
function bankCoinRank(product: Product) {
const text = productText(product)
if (text.includes('نیم')) return 2
if (text.includes('ربع')) return 3
if (text.includes('تمام') || text.includes('امامی') || text.includes('بهار')) return 1
return 99
}
function isBankCoinDenomination(product: Product) {
return bankCoinRank(product) <= 3
}
function isSimorghBar(product: Product) {
return productText(product).includes('سیمرغ')
}
export function useTvMonitorPage() {
const rootEl = ref<HTMLElement | null>(null)
const { isFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(rootEl)
const products = ref<Product[]>([])
const loading = ref(true)
const nowLabel = ref('')
const dateLabel = ref('')
const cursorHidden = ref(false)
const wakeLockSentinel = ref<{ release: () => Promise<void> } | null>(null)
let clockTimer: ReturnType<typeof setInterval> | undefined
let cursorTimer: ReturnType<typeof setTimeout> | undefined
let listTimer: ReturnType<typeof setInterval> | undefined
const brandName = computed(
() => String(getConfigApp('name_fa') || getConfigApp('name') || 'تابلو قیمت'),
)
const brandLogo = computed(() => String(getConfigApp('avatar') || logoFallback))
// API فقط show_on_tv می‌دهد؛ فیلتر کلاینت برای دادهٔ ناقص است
const catalogProducts = computed(() => {
const list = products.value
if (!list.length) return list
const marked = list.filter(isShowOnTv)
return marked.length ? marked : list
})
const parsianCoins = computed(() =>
catalogProducts.value
.filter(isParsianCoin)
.sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)),
)
const bankCoins = computed(() =>
catalogProducts.value
.filter(isBankCoin)
.filter(isBankCoinDenomination)
.sort((a, b) => bankCoinRank(a) - bankCoinRank(b) || Number(b.amount ?? 0) - Number(a.amount ?? 0)),
)
const hasCoinBoard = computed(
() => parsianCoins.value.length > 0 || bankCoins.value.length > 0,
)
const coinRowCount = computed(() =>
Math.max(parsianCoins.value.length, bankCoins.value.length, 1),
)
const simorghBars = computed(() =>
catalogProducts.value
.filter(isSimorghBar)
.sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)),
)
/** تیکر پایین: شمش / آبشده / هر محصول غیرسکه */
const tickerProducts = computed(() =>
catalogProducts.value
.filter((p) => !isParsianCoin(p) && !isBankCoin(p))
.sort((a, b) => Number(a.amount ?? 0) - Number(b.amount ?? 0)),
)
const tickerTitle = computed(() => {
const items = tickerProducts.value
if (!items.length) return ''
if (items.every(isSimorghBar)) return 'شمش سیمرغ'
if (items.every((p) => isMeltedTradeGroup(p.group_type))) return 'آبشده'
return 'محصولات تابلو'
})
const hasBoardContent = computed(
() => hasCoinBoard.value || tickerProducts.value.length > 0,
)
const statusBanner = computed(() => {
void lastPrice.value
const state = getPriceDisplayState()
return state === 'open' ? '' : getPriceDisplayMessage(state)
})
const liveBuy = computed(() => getTradeBuyPrice(lastPrice.value))
const liveSell = computed(() => getTradeSellPrice(lastPrice.value))
const isLive = computed(
() => priceSocketConnected.value || priceStreamLifecycle.value === 'open',
)
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
}
}
function updateClock() {
const now = moment()
nowLabel.value = now.format('HH:mm:ss')
dateLabel.value = now.format('dddd jD jMMMM jYYYY')
}
function hideCursorSoon() {
cursorHidden.value = false
if (cursorTimer) clearTimeout(cursorTimer)
cursorTimer = setTimeout(() => {
cursorHidden.value = true
}, CURSOR_HIDE_MS)
}
async function requestWakeLock() {
try {
const nav = navigator as Navigator & {
wakeLock?: { request: (type: 'screen') => Promise<{ release: () => Promise<void> }> }
}
wakeLockSentinel.value = (await nav.wakeLock?.request('screen')) ?? null
} catch {
wakeLockSentinel.value = null
}
}
async function releaseWakeLock() {
try {
await wakeLockSentinel.value?.release()
} catch {
/* ignore */
}
wakeLockSentinel.value = null
}
async function onVisibilityChange() {
if (document.visibilityState === 'visible') {
await requestWakeLock()
}
}
async function toggleFullscreen(event?: Event) {
event?.stopPropagation()
hideCursorSoon()
try {
if (isFullscreen.value) {
await exitFullscreen()
} else {
await enterFullscreen()
}
} catch {
/* browser may block without gesture */
}
}
function productWeightLabel(product: Product) {
if (isMeltedTradeGroup(product.group_type)) {
return 'قیمت هر گرم'
}
const amount = Number(product.amount ?? 0)
if (!amount) return ''
if (amount < 1) return `${amount * 1000} سوت`
return `${amount} گرم`
}
function simorghWeightLabel(product: Product) {
const amount = Number(product.amount ?? 0)
if (!amount) return product.title
return `${amount} گرم`
}
function tickerItemLabel(product: Product) {
if (isSimorghBar(product)) return simorghWeightLabel(product)
const amount = Number(product.amount ?? 0)
if (isMeltedTradeGroup(product.group_type)) {
return amount > 0 ? `${product.title} · ${amount} گرم` : product.title
}
return amount > 0 ? `${product.title} · ${amount} گرم` : product.title
}
function productBuyLabel(product: Product) {
if (isProductPriceInactive(product)) return NO_QUOTE_MESSAGE
const value = getProductBuyPrice(product)
return value > 0 ? usePrice(value) : '—'
}
function productSellLabel(product: Product) {
if (isProductPriceInactive(product)) return NO_QUOTE_MESSAGE
const value = getProductSellPrice(product)
return value > 0 ? usePrice(value) : '—'
}
function liveQuoteLabel(value: number) {
return value > 0 ? usePrice(value) : '—'
}
onMounted(async () => {
document.documentElement.classList.add(TV_HTML_CLASS)
updateClock()
hideCursorSoon()
clockTimer = setInterval(updateClock, 1000)
window.addEventListener('mousemove', hideCursorSoon)
document.addEventListener('visibilitychange', onVisibilityChange)
await requestWakeLock()
startLastPricePolling()
void getLastPrices()
void loadProducts()
listTimer = setInterval(() => {
void loadProducts()
}, REFRESH_MS)
document.title = `${brandName.value} | تابلو قیمت`
})
onUnmounted(() => {
document.documentElement.classList.remove(TV_HTML_CLASS)
document.title = String(getConfigApp('name_fa') || getConfigApp('name') || '')
if (clockTimer) clearInterval(clockTimer)
if (cursorTimer) clearTimeout(cursorTimer)
if (listTimer) clearInterval(listTimer)
window.removeEventListener('mousemove', hideCursorSoon)
document.removeEventListener('visibilitychange', onVisibilityChange)
void releaseWakeLock()
stopLastPricePolling()
})
return {
rootEl,
brandName,
brandLogo,
nowLabel,
dateLabel,
parsianCoins,
bankCoins,
hasCoinBoard,
hasBoardContent,
coinRowCount,
tickerProducts,
tickerTitle,
statusBanner,
liveBuy,
liveSell,
isLive,
isFullscreen,
cursorHidden,
loading,
productWeightLabel,
tickerItemLabel,
productBuyLabel,
productSellLabel,
liveQuoteLabel,
toggleFullscreen,
}
}
+211
View File
@@ -0,0 +1,211 @@
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { colors } from '@/themeConfig'
export type TvThemeMode = 'light' | 'dark'
const STORAGE_KEY = 'boursecu_tv_theme'
type Rgb = { r: number; g: number; b: number }
const appWhite = String(colors.white)
const appGray50 = String(colors.gray[50])
const appGray100 = String(colors.gray[100])
const appGray500 = String(colors.gray[500])
const appGray700 = String(colors.gray[700])
const appSecondary = String(colors.secondary)
const appTertiary = String(colors.tertiary)
function normalizeHex(hex: unknown): string | null {
if (!hex || typeof hex !== 'string') return null
let value = hex.trim()
if (!value) return null
if (value[0] !== '#') value = `#${value}`
if (/^#[0-9a-fA-F]{3}$/.test(value)) {
value = `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
}
if (!/^#[0-9a-fA-F]{6}$/.test(value)) return null
return value.toLowerCase()
}
function readAppPrimary(): string {
try {
const css = getComputedStyle(document.documentElement)
.getPropertyValue('--App-primary-color')
.trim()
return normalizeHex(css) || normalizeHex(String(colors.secondary)) || '#135953'
} catch {
return normalizeHex(String(colors.secondary)) || '#135953'
}
}
function hexToRgb(hex: string): Rgb | null {
const normalized = normalizeHex(hex)
if (!normalized) return null
const num = parseInt(normalized.slice(1), 16)
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
}
}
function rgbToHex(r: number, g: number, b: number) {
const toHex = (value: number) =>
Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function mix(hexA: string, hexB: string, weight: number) {
const a = hexToRgb(hexA)
const b = hexToRgb(hexB)
if (!a || !b) return hexA
return rgbToHex(
a.r * (1 - weight) + b.r * weight,
a.g * (1 - weight) + b.g * weight,
a.b * (1 - weight) + b.b * weight,
)
}
function brightness(hex: string) {
const rgb = hexToRgb(hex)
if (!rgb) return 0
return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
}
function hexToRgba(hex: string, alpha: number) {
const rgb = hexToRgb(hex)
if (!rgb) return `rgba(15, 23, 42, ${alpha})`
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`
}
function toneForSurface(hex: string, surface: 'light' | 'dark') {
const value = brightness(hex)
if (surface === 'light') {
return value > 165 ? mix(hex, '#1e2229', 0.42) : hex
}
return value < 95 ? mix(hex, appWhite, 0.42) : hex
}
function readStoredTheme(): TvThemeMode | null {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') return stored
} catch {
/* ignore */
}
return null
}
function buildThemeVars(mode: TvThemeMode) {
const isDark = mode === 'dark'
const appPrimary = readAppPrimary()
const pageBg = isDark ? mix('#160910', appPrimary, 0.22) : appWhite
const pageBgSoft = isDark
? mix(pageBg, appPrimary, 0.18)
: mix(appGray50, appPrimary, 0.08)
const text = isDark ? appWhite : '#1e2229'
const muted = isDark ? hexToRgba(appWhite, 0.72) : appGray500
const card = isDark ? mix(pageBg, appWhite, 0.08) : appWhite
const cardText = isDark ? appWhite : '#1e2229'
const cardMuted = isDark ? hexToRgba(appWhite, 0.68) : appGray500
const border = isDark ? hexToRgba(appWhite, 0.12) : hexToRgba(appPrimary, 0.14)
const mediaBg = isDark ? hexToRgba(appWhite, 0.08) : mix(appGray100, appPrimary, 0.06)
const shadow = isDark
? `0 16px 40px ${hexToRgba(appPrimary, 0.28)}`
: `0 14px 32px ${hexToRgba(appPrimary, 0.14)}`
const sell = toneForSurface(appSecondary, isDark ? 'dark' : 'light')
const buy = toneForSurface(appPrimary, isDark ? 'dark' : 'light')
const sellBg = isDark ? hexToRgba(appSecondary, 0.2) : mix(appWhite, appSecondary, 0.14)
const buyBg = isDark ? hexToRgba(appPrimary, 0.22) : mix(appWhite, appPrimary, 0.12)
return {
'--tv-bg': pageBg,
'--tv-bg-soft': pageBgSoft,
'--tv-text': text,
'--tv-muted': muted,
'--tv-card': card,
'--tv-card-text': cardText,
'--tv-card-muted': cardMuted,
'--tv-border': border,
'--tv-primary': appPrimary,
'--tv-secondary': appSecondary,
'--tv-tertiary': appTertiary,
'--tv-sell': sell,
'--tv-buy': buy,
'--tv-sell-bg': sellBg,
'--tv-buy-bg': buyBg,
'--tv-media': mediaBg,
'--tv-logo-bg': appWhite,
'--tv-shadow': shadow,
'--tv-orb-1': hexToRgba(appPrimary, isDark ? 0.28 : 0.14),
'--tv-orb-2': hexToRgba(appTertiary, isDark ? 0.16 : 0.1),
'--tv-quote-bg': isDark ? hexToRgba(appWhite, 0.07) : hexToRgba(appWhite, 0.86),
'--tv-dot': isDark ? hexToRgba(appWhite, 0.28) : hexToRgba(appPrimary, 0.22),
'--tv-badge': isDark ? mix(appPrimary, '#1e2229', 0.35) : mix(appPrimary, '#1e2229', 0.2),
}
}
export function useTvTheme() {
const route = useRoute()
const router = useRouter()
function resolveTheme(): TvThemeMode {
const query = String(route.query.theme || '').toLowerCase()
if (query === 'light' || query === 'dark') return query
return readStoredTheme() ?? 'dark'
}
const theme = ref<TvThemeMode>(resolveTheme())
const themeVars = computed(() => buildThemeVars(theme.value))
watch(
theme,
(mode) => {
document.documentElement.classList.toggle('app-layout--tv-dark', mode === 'dark')
document.documentElement.classList.toggle('app-layout--tv-light', mode === 'light')
document.documentElement.style.setProperty('--tv-page-bg', themeVars.value['--tv-bg'])
},
{ immediate: true },
)
watch(
() => route.query.theme,
(value) => {
if (value === 'light' || value === 'dark') {
theme.value = value
}
},
)
onUnmounted(() => {
document.documentElement.classList.remove('app-layout--tv-dark', 'app-layout--tv-light')
document.documentElement.style.removeProperty('--tv-page-bg')
})
function persistTheme(next: TvThemeMode) {
theme.value = next
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {
/* ignore */
}
const nextQuery = { ...route.query, theme: next }
void router.replace({ query: nextQuery })
}
function toggleTheme(event?: Event) {
event?.stopPropagation()
persistTheme(theme.value === 'dark' ? 'light' : 'dark')
}
return {
theme,
themeVars,
toggleTheme,
}
}
+926
View File
@@ -0,0 +1,926 @@
<template>
<div
ref="rootEl"
class="tv-monitor"
:class="[`tv-monitor--${theme}`, { 'tv-monitor--idle': cursorHidden }]"
:style="themeVars"
dir="rtl"
>
<header class="tv-monitor__header">
<div class="tv-monitor__brand">
<img :src="brandLogo" :alt="brandName" class="tv-monitor__logo" />
<div>
<h1 class="tv-monitor__title">{{ brandName }}</h1>
<p class="tv-monitor__subtitle">تابلو قیمت محصولات</p>
</div>
</div>
<div class="tv-monitor__quote">
<span class="tv-monitor__quote-label">مظنه لحظه‌ای</span>
<div class="tv-monitor__quote-prices">
<div class="tv-monitor__quote-item tv-monitor__quote-item--sell">
<span>فروش</span>
<strong>{{ liveQuoteLabel(liveBuy) }}</strong>
</div>
<div class="tv-monitor__quote-item tv-monitor__quote-item--buy">
<span>خرید</span>
<strong>{{ liveQuoteLabel(liveSell) }}</strong>
</div>
</div>
</div>
<div class="tv-monitor__clock">
<div class="tv-monitor__toolbar">
<button
type="button"
class="tv-monitor__theme-toggle"
:title="theme === 'dark' ? 'تم روشن' : 'تم تیره'"
@click="toggleTheme"
>
<BasicIcon :icon="theme === 'dark' ? 'vuesax-linear:sun-1' : 'vuesax-linear:moon'" size="18" />
<span>{{ theme === 'dark' ? 'تم روشن' : 'تم تیره' }}</span>
</button>
<button
type="button"
class="tv-monitor__theme-toggle"
:title="isFullscreen ? 'خروج از تمام‌صفحه' : 'تمام‌صفحه'"
@click="toggleFullscreen"
>
<BasicIcon
:icon="isFullscreen ? 'vuesax-linear:maximize-circle' : 'vuesax-linear:maximize-4'"
size="18"
/>
<span>{{ isFullscreen ? 'خروج تمام‌صفحه' : 'تمام‌صفحه' }}</span>
</button>
</div>
<div class="tv-monitor__time">{{ nowLabel }}</div>
<div class="tv-monitor__date">{{ dateLabel }}</div>
<div class="tv-monitor__live" :class="{ 'is-on': isLive }">
<span class="tv-monitor__live-dot" />
{{ isLive ? 'زنده' : 'به‌روزرسانی خودکار' }}
</div>
</div>
</header>
<p v-if="statusBanner" class="tv-monitor__banner">{{ statusBanner }}</p>
<main class="tv-monitor__board">
<a-spin :spinning="loading" class="tv-monitor__spin">
<div
v-if="hasCoinBoard"
class="tv-monitor__columns"
:style="{ '--tv-row-count': coinRowCount }"
>
<section class="tv-col">
<header class="tv-col__head">
<h2>سکه پارسیان</h2>
<span>تمام اوزان</span>
</header>
<div class="tv-col__labels">
<span class="tv-col__labels-gap" />
<div class="tv-col__price-heads">
<span class="tv-col__label tv-col__label--sell">فروش</span>
<span class="tv-col__label tv-col__label--buy">خرید</span>
</div>
</div>
<div class="tv-col__list">
<article
v-for="item in parsianCoins"
:key="item.id"
class="tv-row"
>
<div class="tv-row__media">
<ProductImage :product="item" img-class="tv-row__img" />
</div>
<div class="tv-row__meta">
<h3>{{ item.title }}</h3>
<p>{{ productWeightLabel(item) }}</p>
</div>
<div class="tv-row__prices">
<div class="tv-row__price tv-row__price--sell">
<strong>{{ productBuyLabel(item) }}</strong>
</div>
<div class="tv-row__price tv-row__price--buy">
<strong>{{ productSellLabel(item) }}</strong>
</div>
</div>
</article>
<div v-if="!parsianCoins.length" class="tv-col__empty">سکه‌ای برای نمایش نیست</div>
</div>
</section>
<section class="tv-col">
<header class="tv-col__head">
<h2>سکه بانکی</h2>
<span>تمام · نیم · ربع</span>
</header>
<div class="tv-col__labels">
<span class="tv-col__labels-gap" />
<div class="tv-col__price-heads">
<span class="tv-col__label tv-col__label--sell">فروش</span>
<span class="tv-col__label tv-col__label--buy">خرید</span>
</div>
</div>
<div class="tv-col__list">
<article
v-for="item in bankCoins"
:key="item.id"
class="tv-row"
>
<div class="tv-row__media">
<ProductImage :product="item" img-class="tv-row__img" />
</div>
<div class="tv-row__meta">
<h3>{{ item.title }}</h3>
<p>{{ productWeightLabel(item) }}</p>
</div>
<div class="tv-row__prices">
<div class="tv-row__price tv-row__price--sell">
<strong>{{ productBuyLabel(item) }}</strong>
</div>
<div class="tv-row__price tv-row__price--buy">
<strong>{{ productSellLabel(item) }}</strong>
</div>
</div>
</article>
<div v-if="!bankCoins.length" class="tv-col__empty">سکه‌ای برای نمایش نیست</div>
</div>
</section>
</div>
<div v-else-if="!loading && !hasBoardContent" class="tv-monitor__empty">
محصولی برای نمایش روی تابلو انتخاب نشده است
</div>
</a-spin>
</main>
<footer class="tv-monitor__footer">
<div
v-if="tickerProducts.length"
class="tv-ticker"
:style="{ '--tv-ticker-duration': `${Math.max(tickerProducts.length * 7, 36)}s` }"
>
<div class="tv-ticker__label">
<strong>{{ tickerTitle }}</strong>
<span>خرید و فروش</span>
</div>
<div class="tv-ticker__viewport">
<div class="tv-ticker__track">
<article
v-for="item in tickerProducts"
:key="item.id"
class="tv-ticker__item"
>
<span class="tv-ticker__weight">{{ tickerItemLabel(item) }}</span>
<span class="tv-ticker__price tv-ticker__price--sell">
فروش
<b>{{ productBuyLabel(item) }}</b>
</span>
<span class="tv-ticker__price tv-ticker__price--buy">
خرید
<b>{{ productSellLabel(item) }}</b>
</span>
</article>
</div>
</div>
</div>
<div class="tv-monitor__credit" @click.stop>
<AppCreditLink variant="tv" />
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import ProductImage from '@/components/ProductImage.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import AppCreditLink from '@/components/AppCreditLink.vue'
import { useTvMonitorPage } from '@/views/tv/composables/useTvMonitorPage'
import { useTvTheme } from '@/views/tv/composables/useTvTheme'
const { theme, themeVars, toggleTheme } = useTvTheme()
const {
rootEl,
brandName,
brandLogo,
nowLabel,
dateLabel,
parsianCoins,
bankCoins,
hasCoinBoard,
hasBoardContent,
coinRowCount,
tickerProducts,
tickerTitle,
statusBanner,
liveBuy,
liveSell,
isLive,
isFullscreen,
cursorHidden,
loading,
productWeightLabel,
tickerItemLabel,
productBuyLabel,
productSellLabel,
liveQuoteLabel,
toggleFullscreen,
} = useTvMonitorPage()
</script>
<style scoped lang="less">
.tv-monitor {
box-sizing: border-box;
display: flex;
position: relative;
flex-direction: column;
width: 100%;
height: 100%;
padding: clamp(12px, 1.6vh, 24px) clamp(16px, 2vw, 32px);
background:
radial-gradient(1200px 500px at 100% -10%, var(--tv-orb-1), transparent 55%),
radial-gradient(900px 420px at -10% 110%, var(--tv-orb-2), transparent 50%),
linear-gradient(160deg, var(--tv-bg) 0%, var(--tv-bg-soft) 48%, var(--tv-bg) 100%);
color: var(--tv-text);
font-family: iran-sans, sans-serif;
user-select: none;
overflow: hidden;
h1,
h2,
p,
span,
strong,
div,
article {
font-family: inherit;
}
&--idle {
cursor: none;
}
}
.tv-monitor__header {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.9fr;
align-items: center;
gap: 16px;
margin-bottom: clamp(10px, 1.4vh, 20px);
}
.tv-monitor__banner {
margin: 0 0 clamp(10px, 1.2vh, 16px);
padding: 10px 16px;
border-radius: 14px;
background: var(--tv-quote-bg);
border: 1px solid var(--tv-border);
color: var(--tv-muted);
font-size: clamp(12px, 1.05vw, 15px);
text-align: center;
}
.tv-monitor__brand {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.tv-monitor__logo {
width: clamp(52px, 6.5vh, 76px);
height: clamp(52px, 6.5vh, 76px);
object-fit: contain;
background: var(--tv-logo-bg);
border-radius: 16px;
padding: 6px;
}
.tv-monitor__title {
margin: 0;
font-size: clamp(20px, 2.4vw, 36px);
font-weight: 700;
line-height: 1.3;
color: var(--tv-text);
}
.tv-monitor__subtitle {
margin: 2px 0 0;
color: var(--tv-muted);
font-size: clamp(12px, 1.1vw, 16px);
}
.tv-monitor__quote {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.tv-monitor__quote-label {
font-size: 13px;
color: var(--tv-muted);
}
.tv-monitor__quote-prices {
display: flex;
gap: 10px;
}
.tv-monitor__quote-item {
min-width: 180px;
padding: 8px 14px;
border-radius: 14px;
text-align: center;
background: var(--tv-quote-bg);
border: 1px solid var(--tv-border);
span {
display: block;
font-size: 12px;
color: var(--tv-muted);
}
strong {
display: block;
margin-top: 2px;
font-size: clamp(16px, 1.5vw, 22px);
font-weight: 700;
font-family: iran-sans, sans-serif;
direction: ltr;
unicode-bidi: isolate;
}
&--sell strong {
color: var(--tv-sell);
}
&--buy strong {
color: var(--tv-buy);
}
}
.tv-monitor__clock {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: left;
}
.tv-monitor__toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
margin-bottom: 8px;
}
.tv-monitor__theme-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 0;
padding: 6px 12px;
border: 1px solid var(--tv-border);
border-radius: 999px;
background: var(--tv-quote-bg);
color: var(--tv-text);
font-family: iran-sans, sans-serif;
font-size: 12px;
cursor: pointer;
:deep(svg path) {
stroke: currentColor;
}
}
.tv-monitor__time {
font-size: clamp(28px, 3.4vw, 48px);
font-weight: 700;
letter-spacing: 0.04em;
font-family: iran-sans, sans-serif;
direction: ltr;
unicode-bidi: isolate;
line-height: 1;
color: var(--tv-text);
}
.tv-monitor__date {
margin-top: 6px;
color: var(--tv-muted);
font-size: clamp(12px, 1.15vw, 16px);
}
.tv-monitor__live {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
color: var(--tv-muted);
font-size: 12px;
&.is-on {
color: var(--tv-sell);
}
}
.tv-monitor__live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tv-muted);
.is-on & {
background: var(--tv-sell);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--tv-sell) 70%, transparent);
animation: tv-pulse 1.6s infinite;
}
}
.tv-monitor__board {
flex: 1;
min-height: 0;
:deep(.tv-monitor__spin),
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
}
}
.tv-monitor__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(12px, 1.6vw, 22px);
height: 100%;
min-height: 0;
}
.tv-col {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
padding: 12px;
border-radius: 22px;
background: var(--tv-quote-bg);
border: 1px solid var(--tv-border);
}
.tv-col__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding: 0 4px 10px;
border-bottom: 1px solid var(--tv-border);
h2 {
margin: 0;
font-size: clamp(18px, 1.7vw, 28px);
font-weight: 700;
color: var(--tv-text);
}
span {
color: var(--tv-muted);
font-size: clamp(12px, 1vw, 15px);
}
}
.tv-col__labels {
display: grid;
grid-template-columns: 56px minmax(0, 0.9fr) minmax(0, 2fr);
gap: 10px;
align-items: center;
margin-bottom: 8px;
padding: 0 10px;
}
.tv-col__labels-gap {
grid-column: 1 / 3;
}
.tv-col__price-heads {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.tv-col__label {
display: block;
text-align: center;
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 700;
}
.tv-col__label--sell {
color: var(--tv-sell);
}
.tv-col__label--buy {
color: var(--tv-buy);
}
.tv-col__list {
display: grid;
flex: 1;
min-height: 0;
/* با آیتم کم کش نیاید؛ با آیتم زیاد فضای ستون را پر کند */
grid-auto-rows: minmax(
64px,
min(88px, calc((100% - (var(--tv-row-count) - 1) * 8px) / var(--tv-row-count)))
);
align-content: start;
gap: 8px;
overflow: auto;
}
.tv-col__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
color: var(--tv-muted);
font-size: 16px;
}
.tv-row {
position: relative;
display: grid;
grid-template-columns: minmax(44px, 56px) minmax(0, 0.9fr) minmax(0, 2fr);
align-items: center;
gap: 10px;
height: 100%;
max-height: 88px;
min-height: 0;
min-width: 0;
overflow: hidden;
padding: 8px 12px;
border-radius: 16px;
background: var(--tv-card);
color: var(--tv-card-text);
border: 1px solid var(--tv-border);
box-shadow: var(--tv-shadow);
container-type: size;
&--off {
opacity: 0.72;
}
}
.tv-row__badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 7px;
border-radius: 8px;
background: var(--tv-badge);
color: #fff;
font-size: 11px;
}
.tv-row__media {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
height: 100%;
min-height: 0;
border-radius: 12px;
background: var(--tv-media);
}
.tv-row__img {
max-width: 86%;
max-height: 86%;
object-fit: contain;
}
.tv-row__meta {
min-width: 0;
overflow: hidden;
h3 {
margin: 0;
overflow: hidden;
font-size: clamp(13px, 1.2vw, 20px);
font-weight: 700;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--tv-card-text);
}
p {
margin: 4px 0 0;
overflow: hidden;
color: var(--tv-card-muted);
font-size: clamp(11px, 1vw, 15px);
text-overflow: ellipsis;
white-space: nowrap;
}
}
@container (max-height: 64px) {
.tv-row__meta p {
display: none;
}
}
.tv-row__prices {
display: grid;
align-self: stretch;
grid-template-columns: 1fr 1fr;
gap: 8px;
height: 100%;
min-height: 0;
}
.tv-row__price {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
padding: 6px 8px;
border-radius: 12px;
text-align: center;
container-type: inline-size;
span {
display: block;
font-size: 12px;
color: var(--tv-card-muted);
}
strong {
display: block;
max-width: 100%;
margin-top: 0;
overflow: hidden;
font-size: clamp(10px, 12cqi, 16px);
font-weight: 700;
font-family: iran-sans, sans-serif;
white-space: nowrap;
direction: ltr;
unicode-bidi: isolate;
}
&--sell {
background: var(--tv-sell-bg);
strong {
color: var(--tv-sell);
}
}
&--buy {
background: var(--tv-buy-bg);
strong {
color: var(--tv-buy);
}
}
}
.tv-monitor__empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--tv-muted);
font-size: 22px;
}
.tv-monitor__footer {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 12px;
margin-inline: calc(-1 * clamp(16px, 2vw, 32px));
margin-bottom: calc(-1 * clamp(12px, 1.6vh, 24px));
}
.tv-monitor__credit {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
background: #fff;
box-shadow: 0 -2px 4px -2px #0000001a;
}
.tv-ticker {
display: flex;
overflow: hidden;
min-height: 52px;
background: var(--tv-primary);
color: #fff;
box-shadow: 0 -8px 24px color-mix(in srgb, var(--tv-primary) 28%, transparent);
}
.tv-ticker__label {
display: flex;
position: relative;
z-index: 2;
flex-shrink: 0;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 132px;
padding: 8px 18px 8px 22px;
background: color-mix(in srgb, #1e2229 42%, var(--tv-primary));
strong {
font-size: clamp(14px, 1.2vw, 18px);
font-weight: 700;
line-height: 1.3;
}
span {
font-size: 11px;
opacity: 0.82;
}
&::before {
position: absolute;
top: 0;
bottom: 0;
left: -18px;
width: 18px;
background: inherit;
clip-path: polygon(100% 0, 100% 100%, 0 50%);
content: '';
}
}
.tv-ticker__viewport {
display: flex;
overflow: hidden;
flex: 1;
align-items: center;
justify-content: flex-start;
min-width: 0;
direction: ltr;
container-type: inline-size;
mask-image: linear-gradient(to left, transparent 0, #000 36px);
}
.tv-ticker__track {
display: flex;
flex-shrink: 0;
align-items: center;
width: max-content;
direction: rtl;
will-change: transform;
animation: tv-ticker var(--tv-ticker-duration, 40s) linear infinite;
}
.tv-ticker__item {
display: flex;
align-items: center;
gap: 14px;
padding: 0 28px;
white-space: nowrap;
border-left: 1px solid color-mix(in srgb, #fff 28%, transparent);
}
.tv-ticker__weight {
font-size: clamp(14px, 1.15vw, 18px);
font-weight: 700;
}
.tv-ticker__price {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
b {
font-size: clamp(13px, 1.05vw, 16px);
font-weight: 700;
font-family: iran-sans, sans-serif;
white-space: nowrap;
direction: ltr;
unicode-bidi: isolate;
}
&--sell b {
color: #ffe08a;
}
&--buy b {
color: #fff;
}
}
@keyframes tv-ticker {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100cqw);
}
}
@keyframes tv-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--tv-sell) 55%, transparent);
}
70% {
box-shadow: 0 0 0 8px transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@media (max-width: 1100px) {
.tv-monitor__header {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.2fr) auto;
gap: 10px;
}
.tv-monitor__logo {
width: 48px;
height: 48px;
}
.tv-monitor__quote,
.tv-monitor__quote-item {
min-width: 0;
}
.tv-col__labels,
.tv-row {
grid-template-columns: minmax(40px, 48px) minmax(0, 0.85fr) minmax(0, 2.1fr);
}
}
@media (max-width: 720px) {
.tv-monitor__header {
grid-template-columns: 1fr auto;
}
.tv-monitor__quote {
grid-column: 1 / -1;
order: 3;
}
.tv-monitor__columns {
grid-template-columns: 1fr;
overflow: auto;
}
.tv-col {
min-height: 240px;
}
}
</style>
<style lang="less">
html.app-layout--tv,
html.app-layout--tv body,
html.app-layout--tv #app,
html.app-layout--tv .desktopConfig,
html.app-layout--tv .ant-config-provider,
html.app-layout--tv .app-main,
html.app-layout--tv .tv-monitor {
font-family: iran-sans, sans-serif !important;
}
html.app-layout--tv,
html.app-layout--tv body,
html.app-layout--tv #app,
html.app-layout--tv .desktopConfig,
html.app-layout--tv .ant-config-provider,
html.app-layout--tv .app-main {
width: 100% !important;
max-width: none !important;
height: 100dvh !important;
min-height: 100dvh !important;
margin: 0 !important;
overflow: hidden !important;
}
html.app-layout--tv .tv-monitor {
width: 100% !important;
height: 100% !important;
}
html.app-layout--tv body {
display: block !important;
background: var(--tv-page-bg, #0b1220) !important;
}
html.app-layout--tv .app-splash-screen,
html.app-layout--tv #support-button,
html.app-layout--tv #goftino_w {
display: none !important;
}
</style>