This commit is contained in:
2026-06-14 19:11:37 +03:30
parent 3254a6dadb
commit b3113bc398
9 changed files with 185 additions and 40 deletions
+4 -3
View File
@@ -208,10 +208,11 @@ const {
handlePointerMove: onSupportPointerMove, handlePointerMove: onSupportPointerMove,
handlePointerUp: supportPointerUp, handlePointerUp: supportPointerUp,
handlePointerCancel: onSupportPointerCancel handlePointerCancel: onSupportPointerCancel
} = useDraggableFloatingButton('khaneghi_support_button_position', { } = useDraggableFloatingButton('khaneghi_support_button_position_v2', {
width: 118, width: 118,
height: 42, height: 42,
defaultBottomOffset: 120 defaultBottomGap: 12,
defaultRightOffset: 16
}) })
function openSupportLink() { function openSupportLink() {
@@ -322,7 +323,7 @@ function update() {
#support-button { #support-button {
position: fixed; position: fixed;
z-index: 9999; z-index: 5100;
cursor: grab; cursor: grab;
user-select: none; user-select: none;
border-radius: 9999px; border-radius: 9999px;
+1 -1
View File
@@ -1,6 +1,6 @@
import { getStoreCategories } from '@/api/categories' import { getStoreCategories } from '@/api/categories'
/** تب‌های فیلتر محصول — فقط از API دسته‌بندی (هماهنگ با پنل ادمین) */ /** تب‌های فیلتر محصول — دسته‌های فعال که محصول دارند (خودکار از API) */
export async function loadProductCategoryTabNames(): Promise<string[]> { export async function loadProductCategoryTabNames(): Promise<string[]> {
const categories = await getStoreCategories() const categories = await getStoreCategories()
return categories.map((item) => item.name) return categories.map((item) => item.name)
+5 -6
View File
@@ -17,9 +17,6 @@ export interface SupportMessengerSettings {
export const supportMessengerSettings = ref<SupportMessengerSettings>({}) export const supportMessengerSettings = ref<SupportMessengerSettings>({})
export const DEFAULT_SUPPORT_MESSENGER_TEXT =
'جهت شارژ حساب کاربری خود، کلمه "شماره حساب" را برای پشتیبانی ارسال نمایید.'
function getSupportSettingValue( function getSupportSettingValue(
settings: Array<{ key?: string; is_active?: boolean | number; value?: unknown }>, settings: Array<{ key?: string; is_active?: boolean | number; value?: unknown }>,
key: string key: string
@@ -39,9 +36,11 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin
if (/^https?:\/\//i.test(id)) return id if (/^https?:\/\//i.test(id)) return id
const type = (messenger ?? '').trim().toLowerCase() || 'bale' const type = (messenger ?? '').trim().toLowerCase()
const username = id.replace(/^@/, '') const username = id.replace(/^@/, '')
if (!type) return ''
switch (type) { switch (type) {
case 'bale': case 'bale':
if (id.includes('ble.ir')) { if (id.includes('ble.ir')) {
@@ -59,7 +58,7 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin
case 'soroush': case 'soroush':
return `https://splus.ir/splus/${username}` return `https://splus.ir/splus/${username}`
default: default:
return `https://ble.ir/${username}` return ''
} }
} }
@@ -69,7 +68,7 @@ export async function loadSupportMessengerFromSettings() {
if (!settings || !Array.isArray(settings)) return if (!settings || !Array.isArray(settings)) return
supportMessengerSettings.value = { supportMessengerSettings.value = {
messenger: getSupportSettingValue(settings, 'support_messenger') || 'bale', messenger: getSupportSettingValue(settings, 'support_messenger'),
messengerId: getSupportSettingValue(settings, 'support_messenger_id'), messengerId: getSupportSettingValue(settings, 'support_messenger_id'),
messengerText: getSupportSettingValue(settings, 'support_messenger_text'), messengerText: getSupportSettingValue(settings, 'support_messenger_text'),
} }
+90 -15
View File
@@ -1,24 +1,54 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
const DRAG_THRESHOLD = 6 const DRAG_THRESHOLD = 6
const POSITION_VERSION = 1
type FabPosition = { x: number; y: number } type FabPosition = { x: number; y: number }
type SavedFabOffset = {
version: number
dx: number
dy: number
}
type DraggableFloatingOptions = { type DraggableFloatingOptions = {
width?: number width?: number
height?: number height?: number
defaultBottomOffset?: number /** فاصله از پایینِ نوار منو + montzar (px) — مثل تصویر */
defaultBottomGap?: number
defaultRightOffset?: 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( export function useDraggableFloatingButton(
storageKey: string, storageKey: string,
options: DraggableFloatingOptions = {} options: DraggableFloatingOptions = {}
) { ) {
const width = options.width ?? 96 const width = options.width ?? 96
const height = options.height ?? 44 const height = options.height ?? 44
const defaultBottomOffset = options.defaultBottomOffset ?? 120 const defaultBottomGap = options.defaultBottomGap ?? 12
const defaultRightOffset = options.defaultRightOffset ?? 20 const defaultRightOffset = options.defaultRightOffset ?? 16
const pos = ref<FabPosition>({ x: 0, y: 0 }) const pos = ref<FabPosition>({ x: 0, y: 0 })
const dragging = ref(false) const dragging = ref(false)
@@ -44,26 +74,40 @@ export function useDraggableFloatingButton(
function getHorizontalOffset() { function getHorizontalOffset() {
const vw = window.innerWidth const vw = window.innerWidth
const contentWidth = getViewportWidth() const contentWidth = getViewportWidth()
return Math.max(0, (vw - contentWidth) / 2) return Math.max(0, (vw - contentWidth) / 2)
} }
function resetToDefault() { function getDefaultPosition(): FabPosition {
const vh = window.innerHeight const vh = window.innerHeight
const offsetX = getHorizontalOffset() const offsetX = getHorizontalOffset()
const contentWidth = getViewportWidth() const contentWidth = getViewportWidth()
const bottomChrome = readBottomChromeHeight()
pos.value = { return {
x: offsetX + contentWidth - width - defaultRightOffset, 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() { function clampToViewport() {
const vw = window.innerWidth
const vh = window.innerHeight const vh = window.innerHeight
const offsetX = getHorizontalOffset()
const contentWidth = getViewportWidth()
const minX = offsetX
const maxX = offsetX + contentWidth - width
pos.value = { 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)) y: Math.max(0, Math.min(vh - height, pos.value.y))
} }
} }
@@ -72,25 +116,56 @@ export function useDraggableFloatingButton(
try { try {
const raw = localStorage.getItem(storageKey) const raw = localStorage.getItem(storageKey)
if (!raw) return false if (!raw) return false
const parsed = JSON.parse(raw) as FabPosition
if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') { const parsed = JSON.parse(raw) as SavedFabOffset
pos.value = parsed if (
clampToViewport() parsed?.version === POSITION_VERSION
&& typeof parsed.dx === 'number'
&& typeof parsed.dy === 'number'
) {
applySavedOffset(parsed.dx, parsed.dy)
return true return true
} }
} catch { } catch {
// ignore // ignore
} }
return false return false
} }
function savePosition() { 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() { function handleResize() {
clampToViewport() try {
savePosition() 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) { function handlePointerDown(event: PointerEvent) {
+55
View File
@@ -24,3 +24,58 @@ export function useTowDecimal(num: string | number): string | number {
export function useFormat(num: string | number, unit = '') { export function useFormat(num: string | number, unit = '') {
return usePrice(Math.abs(+useTowDecimal(num)), true, 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)))
}
@@ -1,6 +1,13 @@
import { ref } from 'vue' import { ref } from 'vue'
import { getAllPrices } from '@/api/home' import { getAllPrices } from '@/api/home'
const MIN_SCALED_CHART_VALUE = 30_000
const MIN_FULL_CHART_VALUE = 30_000_000
function isValidChartValue(value: number): boolean {
return value > MIN_SCALED_CHART_VALUE || value > MIN_FULL_CHART_VALUE
}
export function usePriceChart() { export function usePriceChart() {
const priceChartSeries = ref([{ name: 'قیمت', data: [] }]) const priceChartSeries = ref([{ name: 'قیمت', data: [] }])
const selection = ref('DAILY') const selection = ref('DAILY')
@@ -16,7 +23,18 @@ export function usePriceChart() {
const response = await getAllPrices() const response = await getAllPrices()
const data = response.data?.prices const data = response.data?.prices
const selectedData = data[selection.value] || [] const selectedData = data[selection.value] || []
priceChartSeries.value[0].data = selectedData.map((item: any) => [item.timestamp, item.value - 1]) let lastValid = 0
priceChartSeries.value[0].data = selectedData
.map((item: { timestamp: number; value: number }) => {
let value = Number(item.value)
if (!isValidChartValue(value)) {
value = lastValid
} else {
lastValid = value
}
return [item.timestamp, value] as [number, number]
})
.filter((point: [number, number]) => point[1] > 0)
} catch (error) { } catch (error) {
console.error('Error fetching prices:', error) console.error('Error fetching prices:', error)
} finally { } finally {
@@ -12,8 +12,8 @@
<div>{{ toJalaliOutOfUTC(item.expire_at) }}</div> <div>{{ toJalaliOutOfUTC(item.expire_at) }}</div>
</div> </div>
<div class="flex justify-between mt-2"> <div class="flex justify-between mt-2">
<div :class="item.gold >= 0? 'text-green-600':'text-red-600'" <div :class="getTransactionGoldAmount(item) >= 0? 'text-green-600':'text-red-600'"
class="text-gray-500 text-xs number-ltr">{{ item.gold }} class="text-gray-500 text-xs number-ltr">{{ formatGoldAmount(getTransactionGoldAmount(item)) }}
<div>{{item.unit}}</div> <div>{{item.unit}}</div>
</div> </div>
<div class="text-gray-500 flex gap-1 text-xs items-center number-ltr"> <div class="text-gray-500 flex gap-1 text-xs items-center number-ltr">
@@ -29,7 +29,7 @@
<div class="text-gray-500">قیمت معامله:</div> <div class="text-gray-500">قیمت معامله:</div>
<div class="text-gray-500 flex gap-1 text-xs items-center"> <div class="text-gray-500 flex gap-1 text-xs items-center">
<!-- <div class="text-base">{{ typeof parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0)) }}</div>--> <!-- <div class="text-base">{{ typeof parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0)) }}</div>-->
<div class="text-base">{{ usePriceWithoutType(parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0))) }}</div> <div class="text-base">{{ usePriceWithoutType(getTransactionTradePrice(item)) }}</div>
ریال ریال
</div> </div>
</div> </div>
@@ -62,16 +62,16 @@
</div> </div>
<div class="flex justify-between mb-3"> <div class="flex justify-between mb-3">
<div class="text-primary">مقدار {{ item.wallet?.product ? item.wallet.product.title : getMeltedGoldTradeLabel() }} مبادله شده:</div> <div class="text-primary">مقدار {{ item.wallet?.product ? item.wallet.product.title : getMeltedGoldTradeLabel() }} مبادله شده:</div>
<div :class="item.gold >= 0? 'text-green-600' : 'text-red-600'" class="flex flex-col items-end text-xs number-ltr"> <div :class="getTransactionGoldAmount(item) >= 0? 'text-green-600' : 'text-red-600'" class="flex flex-col items-end text-xs number-ltr">
<div class="flex items-center"> <div class="flex items-center">
<div class="text-base ml-1">{{ item.gold }}</div> <div class="text-base ml-1">{{ formatGoldAmount(getTransactionGoldAmount(item)) }}</div>
{{ item.unit }} {{ item.unit }}
</div> </div>
<div <div
v-if="item.wallet?.product && getProductTotalWeightLabel(Math.abs(item.gold ?? 0), item.wallet.product)" v-if="item.wallet?.product && getProductTotalWeightLabel(Math.abs(getTransactionGoldAmount(item)), item.wallet.product)"
class="text-gray-500" class="text-gray-500"
> >
{{ getProductTotalWeightLabel(Math.abs(item.gold ?? 0), item.wallet.product) }} {{ getProductTotalWeightLabel(Math.abs(getTransactionGoldAmount(item)), item.wallet.product) }}
</div> </div>
</div> </div>
</div> </div>
@@ -146,7 +146,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import Icon from '@/components/BasicPacks/BasicIcon.vue' import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '../../../../utils/usePrice' import { formatGoldAmount, getTransactionGoldAmount, getTransactionTradePrice, usePriceWithoutType } from '../../../../utils/usePrice'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue' import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue' import BasicButton from '@/components/BasicPacks/BasicButton.vue'
+2 -2
View File
@@ -40,7 +40,7 @@
<div v-if="isGold" class="flex w-full justify-between mt-2"> <div v-if="isGold" class="flex w-full justify-between mt-2">
<div class="text-gray-500">قیمت معامله:</div> <div class="text-gray-500">قیمت معامله:</div>
<div class="text-gray-500 flex gap-1 text-xs items-center"> <div class="text-gray-500 flex gap-1 text-xs items-center">
<div class="text-base">{{ usePriceWithoutType((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0)) }}</div> <div class="text-base">{{ usePriceWithoutType(getTransactionTradePrice(item)) }}</div>
ریال ریال
</div> </div>
</div> </div>
@@ -138,7 +138,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import Icon from "@/components/BasicPacks/BasicIcon.vue"; import Icon from "@/components/BasicPacks/BasicIcon.vue";
import {usePriceWithoutType} from "../../../../utils/usePrice"; import {getTransactionTradePrice, usePriceWithoutType} from "../../../../utils/usePrice";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue"; import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
import {computed, ref} from "vue"; import {computed, ref} from "vue";
import BasicButton from "@/components/BasicPacks/BasicButton.vue"; import BasicButton from "@/components/BasicPacks/BasicButton.vue";
+1 -4
View File
@@ -418,7 +418,6 @@ import { useConfigStore } from '@/store/configStore'
import { isBazretalaProject } from '@/utils/config' import { isBazretalaProject } from '@/utils/config'
import { import {
buildSupportMessengerUrl, buildSupportMessengerUrl,
DEFAULT_SUPPORT_MESSENGER_TEXT,
loadSupportMessengerFromSettings, loadSupportMessengerFromSettings,
supportMessengerSettings, supportMessengerSettings,
} from '@/utils/supportMessenger' } from '@/utils/supportMessenger'
@@ -437,9 +436,7 @@ const withdrawInfModal = ref<boolean>(false)
const config = useConfigStore() const config = useConfigStore()
const supportMessengerText = computed(() => const supportMessengerText = computed(() => supportMessengerSettings.value.messengerText ?? '')
supportMessengerSettings.value.messengerText || DEFAULT_SUPPORT_MESSENGER_TEXT
)
const supportMessengerLink = computed(() => const supportMessengerLink = computed(() =>
buildSupportMessengerUrl( buildSupportMessengerUrl(