diff --git a/src/App.vue b/src/App.vue index 92704c8..5e92074 100644 --- a/src/App.vue +++ b/src/App.vue @@ -208,10 +208,11 @@ const { handlePointerMove: onSupportPointerMove, handlePointerUp: supportPointerUp, handlePointerCancel: onSupportPointerCancel -} = useDraggableFloatingButton('khaneghi_support_button_position', { +} = useDraggableFloatingButton('khaneghi_support_button_position_v2', { width: 118, height: 42, - defaultBottomOffset: 120 + defaultBottomGap: 12, + defaultRightOffset: 16 }) function openSupportLink() { @@ -322,7 +323,7 @@ function update() { #support-button { position: fixed; - z-index: 9999; + z-index: 5100; cursor: grab; user-select: none; border-radius: 9999px; diff --git a/src/utils/categoryTabs.ts b/src/utils/categoryTabs.ts index 1e6ad1d..6fe6b4d 100644 --- a/src/utils/categoryTabs.ts +++ b/src/utils/categoryTabs.ts @@ -1,6 +1,6 @@ import { getStoreCategories } from '@/api/categories' -/** تب‌های فیلتر محصول — فقط از API دسته‌بندی (هماهنگ با پنل ادمین) */ +/** تب‌های فیلتر محصول — دسته‌های فعال که محصول دارند (خودکار از API) */ export async function loadProductCategoryTabNames(): Promise { const categories = await getStoreCategories() return categories.map((item) => item.name) diff --git a/src/utils/storeHours.ts b/src/utils/storeHours.ts new file mode 100644 index 0000000..7173084 --- /dev/null +++ b/src/utils/storeHours.ts @@ -0,0 +1,107 @@ +import { useSettingsStore } from '@/store/settingsStore' +import { lastPrice } from '@/utils/price' + +export interface StoreTradingHours { + start: string + end: string + closePriceEnabled: boolean +} + +export interface EffectiveTradingHours { + start: string + end: string + enforced: boolean +} + +let productTradingHours: { start: string; end: string } | null = null + +function parseTimeToSeconds(time: string): number { + const parts = time.trim().split(':').map(Number) + const [hours = 0, minutes = 0, seconds = 0] = parts + return hours * 3600 + minutes * 60 + seconds +} + +export function formatTradingTimeDisplay(time: string): string { + const parts = time.trim().split(':') + if (parts.length >= 3) { + return `${parts[0]}:${parts[1]}:${parts[2]}` + } + if (parts.length >= 2) { + return `${parts[0]}:${parts[1]}:00` + } + return time +} + +export function setProductTradingHours(start?: string | null, end?: string | null) { + if (start && end) { + productTradingHours = { start, end } + } +} + +export function getStoreTradingHours(): StoreTradingHours { + const settings = useSettingsStore() + const closeRaw = settings.getSettingByKey('close_price') + const closePriceEnabled = + closeRaw === true || + closeRaw === 1 || + String(closeRaw ?? 'false').trim().toLowerCase() === 'true' + + return { + start: String(settings.getSettingByKey('startDay') ?? '00:00'), + end: String(settings.getSettingByKey('endDay') ?? '23:59'), + closePriceEnabled, + } +} + +export function getEffectiveTradingHours(): EffectiveTradingHours { + const settings = getStoreTradingHours() + if (settings.closePriceEnabled) { + return { + start: settings.start, + end: settings.end, + enforced: true, + } + } + + if (productTradingHours?.start && productTradingHours?.end) { + return { + start: productTradingHours.start, + end: productTradingHours.end, + enforced: true, + } + } + + return { + start: settings.start, + end: settings.end, + enforced: false, + } +} + +export function isStoreClosedBySchedule(now = new Date()): boolean { + const { start, end, enforced } = getEffectiveTradingHours() + if (!enforced) { + return false + } + + const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() + const openSeconds = parseTimeToSeconds(start) + const closeSeconds = parseTimeToSeconds(end) + + return currentSeconds < openSeconds || currentSeconds > closeSeconds +} + +export function isStoreClosed(): boolean { + if (lastPrice.value?.closed === true) { + return true + } + + return isStoreClosedBySchedule() +} + +export function getStoreClosedMessage(): string { + const { start, end } = getEffectiveTradingHours() + const open = formatTradingTimeDisplay(start) + const close = formatTradingTimeDisplay(end) + return `فروشگاه در حال حاضر بسته است. ساعت کاری از ${open} تا ${close} می‌باشد.` +} diff --git a/src/utils/supportMessenger.ts b/src/utils/supportMessenger.ts index b6f648e..9516c3f 100644 --- a/src/utils/supportMessenger.ts +++ b/src/utils/supportMessenger.ts @@ -17,9 +17,6 @@ export interface SupportMessengerSettings { export const supportMessengerSettings = ref({}) -export const DEFAULT_SUPPORT_MESSENGER_TEXT = - 'جهت شارژ حساب کاربری خود، کلمه "شماره حساب" را برای پشتیبانی ارسال نمایید.' - function getSupportSettingValue( settings: Array<{ key?: string; is_active?: boolean | number; value?: unknown }>, key: string @@ -39,9 +36,11 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin if (/^https?:\/\//i.test(id)) return id - const type = (messenger ?? '').trim().toLowerCase() || 'bale' + const type = (messenger ?? '').trim().toLowerCase() const username = id.replace(/^@/, '') + if (!type) return '' + switch (type) { case 'bale': if (id.includes('ble.ir')) { @@ -59,7 +58,7 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin case 'soroush': return `https://splus.ir/splus/${username}` default: - return `https://ble.ir/${username}` + return '' } } @@ -69,7 +68,7 @@ export async function loadSupportMessengerFromSettings() { if (!settings || !Array.isArray(settings)) return supportMessengerSettings.value = { - messenger: getSupportSettingValue(settings, 'support_messenger') || 'bale', + messenger: getSupportSettingValue(settings, 'support_messenger'), messengerId: getSupportSettingValue(settings, 'support_messenger_id'), messengerText: getSupportSettingValue(settings, 'support_messenger_text'), } diff --git a/src/utils/useDraggableFloatingButton.ts b/src/utils/useDraggableFloatingButton.ts index 1bb61e8..075ceda 100644 --- a/src/utils/useDraggableFloatingButton.ts +++ b/src/utils/useDraggableFloatingButton.ts @@ -1,24 +1,54 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' const DRAG_THRESHOLD = 6 +const POSITION_VERSION = 1 type FabPosition = { x: number; y: number } +type SavedFabOffset = { + version: number + dx: number + dy: number +} + type DraggableFloatingOptions = { width?: number height?: number - defaultBottomOffset?: number + /** فاصله از پایینِ نوار منو + montzar (px) — مثل تصویر */ + defaultBottomGap?: number defaultRightOffset?: number } +function readBottomChromeHeight(): number { + const styles = getComputedStyle(document.documentElement) + const direct = parseFloat(styles.getPropertyValue('--app-bottom-chrome-height')) + if (!Number.isNaN(direct) && direct > 0) { + return direct + } + + const nav = parseFloat(styles.getPropertyValue('--app-nav-height')) || 80 + const creditOffset = parseFloat(styles.getPropertyValue('--app-credit-footer-offset')) + if (!Number.isNaN(creditOffset) && creditOffset > 0) { + return nav + creditOffset + } + + const probe = document.createElement('div') + probe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;height:var(--app-bottom-chrome-height)' + document.body.appendChild(probe) + const measured = probe.getBoundingClientRect().height + document.body.removeChild(probe) + + return measured > 0 ? measured : nav + 28 +} + export function useDraggableFloatingButton( storageKey: string, options: DraggableFloatingOptions = {} ) { const width = options.width ?? 96 const height = options.height ?? 44 - const defaultBottomOffset = options.defaultBottomOffset ?? 120 - const defaultRightOffset = options.defaultRightOffset ?? 20 + const defaultBottomGap = options.defaultBottomGap ?? 12 + const defaultRightOffset = options.defaultRightOffset ?? 16 const pos = ref({ x: 0, y: 0 }) const dragging = ref(false) @@ -44,26 +74,40 @@ export function useDraggableFloatingButton( function getHorizontalOffset() { const vw = window.innerWidth const contentWidth = getViewportWidth() + return Math.max(0, (vw - contentWidth) / 2) } - function resetToDefault() { + function getDefaultPosition(): FabPosition { const vh = window.innerHeight const offsetX = getHorizontalOffset() const contentWidth = getViewportWidth() + const bottomChrome = readBottomChromeHeight() - pos.value = { + return { x: offsetX + contentWidth - width - defaultRightOffset, - y: vh - defaultBottomOffset - height + y: vh - bottomChrome - defaultBottomGap - height } } + function applySavedOffset(dx: number, dy: number) { + const base = getDefaultPosition() + pos.value = { + x: base.x + dx, + y: base.y + dy + } + clampToViewport() + } + function clampToViewport() { - const vw = window.innerWidth const vh = window.innerHeight + const offsetX = getHorizontalOffset() + const contentWidth = getViewportWidth() + const minX = offsetX + const maxX = offsetX + contentWidth - width pos.value = { - x: Math.max(0, Math.min(vw - width, pos.value.x)), + x: Math.max(minX, Math.min(maxX, pos.value.x)), y: Math.max(0, Math.min(vh - height, pos.value.y)) } } @@ -72,25 +116,56 @@ export function useDraggableFloatingButton( try { const raw = localStorage.getItem(storageKey) if (!raw) return false - const parsed = JSON.parse(raw) as FabPosition - if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') { - pos.value = parsed - clampToViewport() + + const parsed = JSON.parse(raw) as SavedFabOffset + if ( + parsed?.version === POSITION_VERSION + && typeof parsed.dx === 'number' + && typeof parsed.dy === 'number' + ) { + applySavedOffset(parsed.dx, parsed.dy) return true } } catch { // ignore } + return false } function savePosition() { - localStorage.setItem(storageKey, JSON.stringify(pos.value)) + const base = getDefaultPosition() + const payload: SavedFabOffset = { + version: POSITION_VERSION, + dx: pos.value.x - base.x, + dy: pos.value.y - base.y + } + localStorage.setItem(storageKey, JSON.stringify(payload)) + } + + function resetToDefault() { + pos.value = getDefaultPosition() } function handleResize() { - clampToViewport() - savePosition() + try { + const raw = localStorage.getItem(storageKey) + if (raw) { + const parsed = JSON.parse(raw) as SavedFabOffset + if ( + parsed?.version === POSITION_VERSION + && typeof parsed.dx === 'number' + && typeof parsed.dy === 'number' + ) { + applySavedOffset(parsed.dx, parsed.dy) + return + } + } + } catch { + // ignore + } + + resetToDefault() } function handlePointerDown(event: PointerEvent) { diff --git a/src/utils/usePrice.ts b/src/utils/usePrice.ts index f69748d..0bf0c34 100644 --- a/src/utils/usePrice.ts +++ b/src/utils/usePrice.ts @@ -24,3 +24,58 @@ export function useTowDecimal(num: string | number): string | number { export function useFormat(num: string | number, unit = '') { return usePrice(Math.abs(+useTowDecimal(num)), true, unit) } + +type TransactionPriceSource = { + cash?: number | null + gold?: number | null + order?: { + price?: number | null + total_price?: number | null + amount?: number | null + } | null +} + +export function getTransactionTradePrice(item: TransactionPriceSource): number { + const orderPrice = Number(item.order?.price) + if (Number.isFinite(orderPrice) && orderPrice > 0) { + return Math.round(orderPrice) + } + + const cashAmount = Math.abs(Number(item.cash) || 0) + const goldAmount = + Math.abs(Number(item.gold) || 0) || Math.abs(Number(item.order?.amount) || 0) + + if (goldAmount > 0 && cashAmount > 0) { + return Math.round(cashAmount / goldAmount) + } + + const totalPrice = Number(item.order?.total_price) + if (Number.isFinite(totalPrice) && totalPrice > 0) { + return Math.round(totalPrice) + } + + return 0 +} + +export function getTransactionGoldAmount(item: TransactionPriceSource): number { + const gold = Number(item.gold) || 0 + if (Math.abs(gold) > 0) { + return gold + } + + const orderAmount = Number(item.order?.amount) || 0 + if (orderAmount > 0) { + const cash = Number(item.cash) || 0 + if (cash < 0) return orderAmount + if (cash > 0) return -orderAmount + return orderAmount + } + + return gold +} + +export function formatGoldAmount(amount?: number | null): string { + const num = Number(amount) + if (!Number.isFinite(num)) return '0' + return String(Number(num.toFixed(3))) +} diff --git a/src/views/base/copper&silver/index.vue b/src/views/base/copper&silver/index.vue index 65369b3..b28432f 100644 --- a/src/views/base/copper&silver/index.vue +++ b/src/views/base/copper&silver/index.vue @@ -239,6 +239,7 @@ import {useConfigStore} from "@/store/configStore"; import { calcFeeInGrams } from '@/utils/serviceFee' import { formatCashWalletBalanceText, formatGoldWalletBalanceText } from '@/utils/productDealMethod' import { getCashWalletBalance } from '@/utils/productAssets' +import { isStoreClosed, getStoreClosedMessage } from '@/utils/storeHours' const infoVisible = ref(false) const walletStore = useWalletStore() @@ -325,6 +326,7 @@ const equivalentPrice = computed(() => usePriceWithoutType(feeInRial.value.toFix const disableTransaction = computed(() => loading.value || + isStoreClosed() || form.value?.goldValue === undefined || form.value?.goldValue === '' || form.value?.goldCash === '' || @@ -640,10 +642,18 @@ async function initPrice() { } function okConfirm() { + if (isStoreClosed()) { + message.warning(getStoreClosedMessage()) + return + } confirmAction.value = true } async function ok() { + if (isStoreClosed()) { + message.warning(getStoreClosedMessage()) + return + } try { loading.value = true infoVisible.value = true diff --git a/src/views/base/gold/index.vue b/src/views/base/gold/index.vue index e9431df..3296c9e 100644 --- a/src/views/base/gold/index.vue +++ b/src/views/base/gold/index.vue @@ -236,6 +236,7 @@ import type { Product } from '@/models/products' import { useFundUserStore } from '@/store/fundStore' import {useConfigStore} from "@/store/configStore"; import { calcFeeInGrams, loadServiceFeeFromSettings } from '@/utils/serviceFee' +import { isStoreClosed, getStoreClosedMessage } from '@/utils/storeHours' import { formatCashWalletBalanceText, getWeightUnit } from '@/utils/productDealMethod' import { formatProductWalletBalanceText, getCashWalletBalance, getProductInventoryQuantity } from '@/utils/productAssets' import { useWalletAssets } from '@/composables/useWalletAssets' @@ -344,6 +345,7 @@ const priceUnavailable = computed(() => { const disableTransaction = computed(() => loading.value || + isStoreClosed() || form.value?.goldValue === undefined || form.value?.goldValue === '' || form.value?.goldCash === '' || @@ -633,6 +635,10 @@ async function initPrice() { } function okConfirm() { + if (isStoreClosed()) { + message.warning(getStoreClosedMessage()) + return + } if (cashValidationMessage.value) { message.warning(cashValidationMessage.value) return @@ -641,6 +647,10 @@ function okConfirm() { } async function ok() { + if (isStoreClosed()) { + message.warning(getStoreClosedMessage()) + return + } try { loading.value = true infoVisible.value = true diff --git a/src/views/base/home/index.vue b/src/views/base/home/index.vue index 0c50ce7..8963b25 100644 --- a/src/views/base/home/index.vue +++ b/src/views/base/home/index.vue @@ -38,7 +38,7 @@ - + @@ -89,6 +89,7 @@ import {useConfigStore} from '@/store/configStore' import {useFundUserStore} from '@/store/fundStore' import {usePriceWithoutType} from '../../../utils/usePrice' import {getLastPrices, lastPrice, price, startLastPricePolling, stopLastPricePolling} from '@/utils/price' +import { getStoreClosedMessage, isStoreClosed } from '@/utils/storeHours' import {getConstByKey} from '@/api/const' import {message, Modal} from 'ant-design-vue' @@ -126,9 +127,11 @@ const { assetsSummary, loadWalletAssets } = useWalletAssets() // Config const isGaroosi = computed(() => configStore.config.name === 'garoosi') const closedMessage = computed(() => userStore.getValueByKey('closed_message')) -const showClosedMessage = computed(() => - closedMessage.value && - lastPrice?.value?.price_last === 0) +const marketClosedText = computed(() => { + if (!isStoreClosed()) return '' + return closedMessage.value || getStoreClosedMessage() +}) +const showClosedMessage = computed(() => !!marketClosedText.value) // State diff --git a/src/views/base/products/components/DealProduct.vue b/src/views/base/products/components/DealProduct.vue index d41776c..f6275fc 100644 --- a/src/views/base/products/components/DealProduct.vue +++ b/src/views/base/products/components/DealProduct.vue @@ -297,6 +297,7 @@ import { } from '@/utils/productOrderLimit' import { useWalletAssets } from '@/composables/useWalletAssets' import { formatProductWalletBalanceText, getCashWalletBalance, getProductInventoryQuantity } from '@/utils/productAssets' +import { isStoreClosed, getStoreClosedMessage } from '@/utils/storeHours' const typeDeal = computed(() => getProductDealMethodOptions(props?.ProductItem?.group_type, isBuy.value)) @@ -438,6 +439,7 @@ const cashValidationMessage = computed(() => const disableTransaction = computed(() => loading.value || + isStoreClosed() || form.value?.goldValue === undefined || form.value?.goldValue === '' || form.value?.goldCash === '' || @@ -795,11 +797,19 @@ async function initPrice() { // }) function okConfirm() { + if (isStoreClosed()) { + message.warning(getStoreClosedMessage()) + return + } confirmAction.value = true refInput.value?.validateFields() } async function ok() { + if (isStoreClosed()) { + message.warning(getStoreClosedMessage()) + return + } try { loading.value = true infoVisible.value = true diff --git a/src/views/base/products/index.vue b/src/views/base/products/index.vue index 2ff0e1b..478f440 100644 --- a/src/views/base/products/index.vue +++ b/src/views/base/products/index.vue @@ -13,6 +13,15 @@ +
+
مشتریان گرامی
+

{{ storeClosedMessage }}

+

از همراهی و اعتماد شما سپاسگزاریم.🤍

+
+
مشتریان گرامی
-

فروشگاه در حال حاضر بسته است.

-

ساعت کاری فروشگاه از {{ openTime }} تا {{ closeTime }} می‌باشد.

+

{{ storeClosedMessage }}

از همراهی و اعتماد شما سپاسگزاریم.🤍

@@ -103,7 +111,15 @@ import { isMetalCommodityGroup, isMeltedTradeGroup } from '@/utils/productCatego import { isBoursecuDealProductGroup } from '@/utils/productDealMethod' import { loadServiceFeeFromSettings } from '@/utils/serviceFee' import { loadProductCategoryTabNames } from '@/utils/categoryTabs' -import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price' +import { getLastPrices, lastPrice, startLastPricePolling, stopLastPricePolling } from '@/utils/price' +import { + formatTradingTimeDisplay, + getEffectiveTradingHours, + getStoreClosedMessage, + isStoreClosed, + setProductTradingHours +} from '@/utils/storeHours' +import { useSettingsStore } from '@/store/settingsStore' const activekey = ref('طلا' ||'آبشده') const showFilter = ref(false) @@ -113,21 +129,29 @@ const closeTime = ref('') const openTime = ref('') const isAfterCloseTime = ref(false) const isProductInactive = ref(false) +const storeIsClosed = computed(() => isStoreClosed()) +const storeClosedMessage = computed(() => getStoreClosedMessage()) -watch([openTime, closeTime], ([open, close]) => { - if (!open || !close) return - const now = new Date() - const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() - const [oh, om, os] = open.split(':').map(Number) - const [ch, cm, cs] = close.split(':').map(Number) - const openSeconds = oh * 3600 + om * 60 + os - const closeSeconds = ch * 3600 + cm * 60 + cs - isAfterCloseTime.value = currentSeconds < openSeconds || currentSeconds > closeSeconds +watch([openTime, closeTime], () => { + syncStoreClosedState() }) +function syncStoreClosedState() { + if (isStoreClosed()) { + showFilter.value = false + isAfterCloseTime.value = true + } +} + function clickProduct(item?: Product) { if (!item?.show) return + if (isStoreClosed()) { + showFilter.value = false + isAfterCloseTime.value = true + return + } + if (isProductPriceInactive(item)) { showFilter.value = false isProductInactive.value = true @@ -144,6 +168,11 @@ function onProductOrdered() { } watch(showFilter, (open) => { + if (open && isStoreClosed()) { + showFilter.value = false + isAfterCloseTime.value = true + return + } if (!open) { orderSheetKey.value += 1 } @@ -161,6 +190,7 @@ const firstItems = ref() const items = ref() const typeItems = ref([]) let productListInterval: ReturnType | undefined +let storeHoursInterval: ReturnType | undefined async function loadCategoryTabs() { try { @@ -212,22 +242,30 @@ async function refreshProducts() { } } +watch(() => lastPrice.value?.closed, syncStoreClosedState) + onMounted(async () => { try { loading.value = true + await useSettingsStore().fetchSettings() await getLastPrices() startLastPricePolling() items.value = await getProducts() if (items.value?.length) { - closeTime.value = items.value[0].auto_close_at - openTime.value = items.value[0].auto_open_at + setProductTradingHours(items.value[0].auto_open_at, items.value[0].auto_close_at) } + const hours = getEffectiveTradingHours() + openTime.value = formatTradingTimeDisplay(hours.start) + closeTime.value = formatTradingTimeDisplay(hours.end) + syncStoreClosedState() + await loadCategoryTabs() firstItems.value = items.value await loadServiceFeeFromSettings() productListInterval = setInterval(refreshProducts, 10000) + storeHoursInterval = setInterval(syncStoreClosedState, 30000) } catch (e) { console.log(e) @@ -239,7 +277,9 @@ onMounted(async () => { } if (router.currentRoute.value.query.state) { - if (router.currentRoute.value.query.product) { + if (isStoreClosed()) { + isAfterCloseTime.value = true + } else if (router.currentRoute.value.query.product) { clickProduct(items.value?.find(x => x.title === 'سکه')) } else { clickProduct(items.value?.find(x => x.title === 'آبشده نقدی')) @@ -253,6 +293,10 @@ onUnmounted(() => { clearInterval(productListInterval) productListInterval = undefined } + if (storeHoursInterval) { + clearInterval(storeHoursInterval) + storeHoursInterval = undefined + } }) // watch(lastPrice, async () => { // // console.log(items); diff --git a/src/views/base/transactions/components/Item.vue b/src/views/base/transactions/components/Item.vue index ba318bb..f97f508 100644 --- a/src/views/base/transactions/components/Item.vue +++ b/src/views/base/transactions/components/Item.vue @@ -12,8 +12,8 @@
{{ toJalaliOutOfUTC(item.expire_at) }}
-
{{ item.gold }} +
{{ formatGoldAmount(getTransactionGoldAmount(item)) }}
{{item.unit}}
@@ -29,7 +29,7 @@
قیمت معامله:
-
{{ usePriceWithoutType(parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0))) }}
+
{{ usePriceWithoutType(getTransactionTradePrice(item)) }}
ریال
@@ -62,16 +62,16 @@
مقدار {{ item.wallet?.product ? item.wallet.product.title : getMeltedGoldTradeLabel() }} مبادله شده:
-
+
-
{{ item.gold }}
+
{{ formatGoldAmount(getTransactionGoldAmount(item)) }}
{{ item.unit }}
- {{ getProductTotalWeightLabel(Math.abs(item.gold ?? 0), item.wallet.product) }} + {{ getProductTotalWeightLabel(Math.abs(getTransactionGoldAmount(item)), item.wallet.product) }}
@@ -146,7 +146,7 @@