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:
@@ -0,0 +1,35 @@
|
||||
export function getDefaultAboutSoftwareText(projectName: string) {
|
||||
const name = projectName.trim() || 'این نرمافزار'
|
||||
return `${name} بستری امن و آنلاین برای خرید و فروش طلا فراهم کرده تا کاربران بتوانند سرمایههای خود را بهسادگی مدیریت کنند.\n\nهدف ما ارائه تجربهای سریع، شفاف و کاربرپسند در معاملات طلا است.`
|
||||
}
|
||||
|
||||
function looksLikeHtml(value: string) {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(value)
|
||||
}
|
||||
|
||||
export type AboutSoftwareBody =
|
||||
| { kind: 'html'; html: string }
|
||||
| { kind: 'text'; text: string }
|
||||
|
||||
/** اولویت با تنظیم about_software؛ در صورت خالی بودن، متن پیشفرض */
|
||||
export function resolveAboutSoftwareBody(
|
||||
raw: unknown,
|
||||
projectName: string,
|
||||
): AboutSoftwareBody {
|
||||
const value = String(raw ?? '').trim()
|
||||
if (!value) {
|
||||
return { kind: 'text', text: getDefaultAboutSoftwareText(projectName) }
|
||||
}
|
||||
if (looksLikeHtml(value)) {
|
||||
const stripped = value
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!stripped) {
|
||||
return { kind: 'text', text: getDefaultAboutSoftwareText(projectName) }
|
||||
}
|
||||
return { kind: 'html', html: value }
|
||||
}
|
||||
return { kind: 'text', text: value }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="about-software-page-root">
|
||||
<div v-if="isDesktop" class="daboutus">
|
||||
<div class="daboutus__container">
|
||||
<header class="daboutus-topbar">
|
||||
<button type="button" class="daboutus-topbar__back" @click="router.back()">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
|
||||
<span>بازگشت</span>
|
||||
</button>
|
||||
<div class="daboutus-topbar__meta">
|
||||
<p class="daboutus-topbar__eyebrow">اطلاعات</p>
|
||||
<h1 class="daboutus-topbar__title">درباره نرمافزار</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="daboutus-surface daboutus-main">
|
||||
<div class="daboutus-main__intro">
|
||||
<div class="daboutus-main__logo">
|
||||
<img :src="config.config?.avatar" alt="" class="daboutus-main__avatar" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="daboutus-main__project">{{ projectName }}</h2>
|
||||
<p class="daboutus-main__tagline">معرفی نرمافزار و خدمات</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="daboutus-main__scroll">
|
||||
<div class="daboutus-content about-software-card">
|
||||
<div
|
||||
v-if="body.kind === 'html'"
|
||||
class="about-software-body about-software-html"
|
||||
v-html="body.html"
|
||||
/>
|
||||
<div v-else class="about-software-body whitespace-pre-line">
|
||||
{{ body.text }}
|
||||
</div>
|
||||
<div class="about-software-enamad">
|
||||
<EnamadBadge size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mobile-info-page">
|
||||
<div class="mobile-info-page__bg" aria-hidden="true">
|
||||
<img :src="img" alt="" />
|
||||
</div>
|
||||
<div class="wrap-fix mobile-top-chrome mobile-info-page__header bg-white px-3 pb-3 text-black font-bold drop-shadow flex">
|
||||
<div class="cursor-pointer" @click="router.back()">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" />
|
||||
</div>
|
||||
<div class="mr-2">درباره نرمافزار</div>
|
||||
</div>
|
||||
<div class="mobile-info-page__main about-software-main">
|
||||
<div class="border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] about-software-card mobile-content-box">
|
||||
<div class="mobile-content-box__scroll about-software-card__scroll">
|
||||
<img
|
||||
:src="config.config?.avatar"
|
||||
class="about-software-avatar"
|
||||
alt=""
|
||||
/>
|
||||
<h2 class="text-sm font-bold mb-2">درباره نرمافزار</h2>
|
||||
<div
|
||||
v-if="body.kind === 'html'"
|
||||
class="about-software-body about-software-html"
|
||||
v-html="body.html"
|
||||
/>
|
||||
<div v-else class="about-software-body whitespace-pre-line">
|
||||
{{ body.text }}
|
||||
</div>
|
||||
<div class="about-software-enamad">
|
||||
<EnamadBadge size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import EnamadBadge from '@/components/EnamadBadge.vue'
|
||||
import img from '@/assets/img/user_ly1.svg'
|
||||
import router from '@/router'
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { resolveAboutSoftwareBody } from './aboutSoftwareContent'
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
const config = useConfigStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const projectName = computed(
|
||||
() => config.config?.name_fa || config.config?.name || '',
|
||||
)
|
||||
|
||||
const body = computed(() => {
|
||||
void settingsStore.settings
|
||||
return resolveAboutSoftwareBody(
|
||||
settingsStore.getSettingByKey('about_software'),
|
||||
projectName.value,
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void settingsStore.fetchSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.about-software-page-root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.about-software-main {
|
||||
padding-right: 1rem !important;
|
||||
padding-left: 1rem !important;
|
||||
}
|
||||
|
||||
.about-software-card {
|
||||
padding: 0.5rem 0.625rem;
|
||||
}
|
||||
|
||||
.about-software-card__scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.about-software-avatar {
|
||||
width: 6.5rem;
|
||||
margin: 0.75rem auto 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.about-software-body {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
}
|
||||
|
||||
.about-software-html :deep(p) {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
}
|
||||
|
||||
.about-software-html :deep(p + p) {
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.about-software-html :deep(ul) {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-right: 1.1rem;
|
||||
list-style: disc;
|
||||
font-size: 0.75rem;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.about-software-html :deep(li + li) {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.about-software-enamad {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -155,39 +155,20 @@
|
||||
|
||||
<BasicActionSheet v-model:show="confirmAction">
|
||||
<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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="tv-board">
|
||||
<header class="tv-board__header">
|
||||
<h1 class="tv-board__title">{{ shopName }}</h1>
|
||||
<time class="tv-board__clock" dir="ltr">{{ clockText }}</time>
|
||||
</header>
|
||||
|
||||
<p v-if="statusBanner" class="tv-board__banner">{{ statusBanner }}</p>
|
||||
|
||||
<div v-if="loading" class="tv-board__state">
|
||||
<a-spin />
|
||||
</div>
|
||||
<div v-else-if="!rows.length" class="tv-board__state">
|
||||
<BasicIcon icon="vuesax-linear:monitor" size="42" />
|
||||
<span>محصولی برای نمایش روی تابلو انتخاب نشده است</span>
|
||||
</div>
|
||||
<div v-else class="tv-board__table">
|
||||
<div class="tv-board__row tv-board__row--head">
|
||||
<span>محصول</span>
|
||||
<span>خرید</span>
|
||||
<span>فروش</span>
|
||||
</div>
|
||||
<div v-for="row in rows" :key="row.id" class="tv-board__row">
|
||||
<span class="tv-board__name">{{ row.title }}</span>
|
||||
<span class="tv-board__buy" dir="ltr">{{ row.buyText }}</span>
|
||||
<span class="tv-board__sell" dir="ltr">{{ row.sellText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { getTvProducts } from '@/api/products'
|
||||
import type { Product } from '@/models/products'
|
||||
import { getConfigApp } from '@/utils/config'
|
||||
import { getProductBuyPrice, getProductSellPrice, isProductPriceInactive } from '@/utils/productPrice'
|
||||
import { lastPrice, getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
|
||||
import { getPriceDisplayMessage, getPriceDisplayState, NO_QUOTE_MESSAGE } from '@/utils/storeHours'
|
||||
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||
import { initPriceRealtimeChannel } from '@/utils/priceSocket'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
|
||||
const REFRESH_MS = 15000
|
||||
|
||||
const loading = ref(true)
|
||||
const products = ref<Product[]>([])
|
||||
const now = ref(Date.now())
|
||||
|
||||
let clockTimer: ReturnType<typeof setInterval> | undefined
|
||||
let listTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const shopName = computed(
|
||||
() => String(getConfigApp('name_fa') || getConfigApp('name') || 'تابلو قیمت'),
|
||||
)
|
||||
|
||||
const clockText = computed(() =>
|
||||
new Date(now.value).toLocaleTimeString('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}),
|
||||
)
|
||||
|
||||
const statusBanner = computed(() => {
|
||||
void lastPrice.value
|
||||
const state = getPriceDisplayState()
|
||||
return state === 'open' ? '' : getPriceDisplayMessage(state)
|
||||
})
|
||||
|
||||
function isShowOnTv(product: Product): boolean {
|
||||
const value = product.show_on_tv as unknown
|
||||
return value === true || value === 1 || value === '1' || value === 'true'
|
||||
}
|
||||
|
||||
const rows = computed(() => {
|
||||
void lastPrice.value
|
||||
// API فقط محصولات تابلو را میدهد؛ اگر فیلد show_on_tv نبود، همه را نشان بده
|
||||
const list = products.value
|
||||
const marked = list.filter(isShowOnTv)
|
||||
const source = marked.length ? marked : list
|
||||
return source.map((product) => {
|
||||
if (isProductPriceInactive(product)) {
|
||||
return {
|
||||
id: product.id,
|
||||
title: product.title,
|
||||
buyText: NO_QUOTE_MESSAGE,
|
||||
sellText: NO_QUOTE_MESSAGE,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: product.id,
|
||||
title: product.title,
|
||||
buyText: usePriceWithoutType(getProductBuyPrice(product)),
|
||||
sellText: usePriceWithoutType(getProductSellPrice(product)),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function loadProducts() {
|
||||
try {
|
||||
const list = await getTvProducts()
|
||||
products.value = Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
if (!products.value.length) {
|
||||
products.value = []
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const settingsStore = useSettingsStore()
|
||||
initPriceRealtimeChannel((key) => settingsStore.getSettingByKey(key))
|
||||
startLastPricePolling()
|
||||
void getLastPrices()
|
||||
void loadProducts()
|
||||
clockTimer = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
listTimer = setInterval(() => {
|
||||
void loadProducts()
|
||||
}, REFRESH_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopLastPricePolling()
|
||||
if (clockTimer) {
|
||||
clearInterval(clockTimer)
|
||||
}
|
||||
if (listTimer) {
|
||||
clearInterval(listTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tv-board {
|
||||
@apply min-h-screen w-full flex flex-col gap-5 p-6 text-white bg-gray-950;
|
||||
}
|
||||
|
||||
.tv-board__header {
|
||||
@apply flex items-center justify-between gap-4;
|
||||
}
|
||||
|
||||
.tv-board__title {
|
||||
@apply m-0 text-2xl font-bold;
|
||||
}
|
||||
|
||||
.tv-board__clock {
|
||||
@apply text-xl font-bold text-gray-300;
|
||||
}
|
||||
|
||||
.tv-board__banner {
|
||||
@apply m-0 rounded-xl bg-gray-800 px-4 py-3 text-center text-sm text-gray-200;
|
||||
}
|
||||
|
||||
.tv-board__state {
|
||||
@apply flex flex-1 flex-col items-center justify-center gap-3 text-gray-400;
|
||||
}
|
||||
|
||||
.tv-board__table {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.tv-board__row {
|
||||
@apply grid grid-cols-3 items-center gap-4 rounded-xl bg-gray-900 px-4 py-3;
|
||||
}
|
||||
|
||||
.tv-board__row--head {
|
||||
@apply bg-gray-800 text-sm text-gray-400;
|
||||
}
|
||||
|
||||
.tv-board__name {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
|
||||
.tv-board__buy {
|
||||
@apply text-xl font-bold text-green-400;
|
||||
}
|
||||
|
||||
.tv-board__sell {
|
||||
@apply text-xl font-bold text-red-400;
|
||||
}
|
||||
</style>
|
||||
@@ -21,6 +21,8 @@ import { getReferralShareText, getReferralShareTitle, shareReferralText } from '
|
||||
import { normalizeAuthFormFields } from '@/utils/persianDigits'
|
||||
import { 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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user