Merge origin/modular into feature/responsive-layout-refactor.
Keep the responsive layout refactor while bringing in modular updates for support UI, pricing, categories, and config loading. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { getStoreCategories } from '@/api/categories'
|
||||
|
||||
/** تبهای فیلتر محصول — فقط از API دستهبندی (هماهنگ با پنل ادمین) */
|
||||
export async function loadProductCategoryTabNames(): Promise<string[]> {
|
||||
const categories = await getStoreCategories()
|
||||
return categories.map((item) => item.name)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {computed, ref} from 'vue'
|
||||
import type {Rule} from 'ant-design-vue/es/form'
|
||||
import {useWalletStore} from "@/store/walletStore";
|
||||
import { isCashLikeDealMethod, isMainMetalWalletDealMethod, getInsufficientCashWalletMessage, getInsufficientGoldWalletMessage } from '@/utils/productDealMethod'
|
||||
import { getCashWalletBalance } from '@/utils/productAssets'
|
||||
|
||||
export const mobileValidation = (title?: string): Rule => {
|
||||
const label = title ?? 'شماره تلفن'
|
||||
@@ -160,13 +161,14 @@ const checkNumber = async (_rule: Rule, value: number) => {
|
||||
return Promise.reject('استفاده از اعداد اعشاری مجاز نمی باشد')
|
||||
}
|
||||
if (isBuyP){
|
||||
if (!walletCash?.walletInfo?.cash && isCashLikeDealMethod(methodP)){
|
||||
const availableCash = getCashWalletBalance(walletCash?.walletInfo)
|
||||
if (!availableCash && isCashLikeDealMethod(methodP)){
|
||||
return Promise.reject(getInsufficientCashWalletMessage(groupTypeP))
|
||||
}
|
||||
if (!walletCash?.walletInfo?.gold && isMainMetalWalletDealMethod(methodP)){
|
||||
return Promise.reject(getInsufficientGoldWalletMessage(groupTypeP, methodP))
|
||||
}
|
||||
if (priceP * value > walletCash?.walletInfo?.cash && isCashLikeDealMethod(methodP))
|
||||
if (priceP * value > availableCash && isCashLikeDealMethod(methodP))
|
||||
return Promise.reject(getInsufficientCashWalletMessage(groupTypeP))
|
||||
if (amountP * value > walletCash?.walletInfo?.gold && isMainMetalWalletDealMethod(methodP))
|
||||
return Promise.reject(getInsufficientGoldWalletMessage(groupTypeP, methodP))
|
||||
|
||||
@@ -335,17 +335,32 @@ export function getTotalMetalWeight(
|
||||
return Number(walletInfo?.treasure ?? walletInfo?.gold_sum ?? walletInfo?.count_gold ?? 0)
|
||||
}
|
||||
|
||||
/** فقط موجودی نقدی کیف اصلی — فیلد gold (مثل 0.3 کیلوگرم) جزو دارایی ریالی نیست */
|
||||
/** موجودی ریالی کیف اصلی — با main_wallet_products فیلد cash ممکن است کیف محصول باشد */
|
||||
export function getRialCashAmount(
|
||||
walletInfo?: Record<string, unknown> | null,
|
||||
wallets?: UserWalletLine[] | null
|
||||
): number {
|
||||
const aggregated = walletInfo?.count_cash_wallet ?? walletInfo?.count_cash
|
||||
if (aggregated != null && aggregated !== '') {
|
||||
const parsed = Number(aggregated)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
const mainCash = getMainWalletCash(wallets)
|
||||
if (mainCash > 0) {
|
||||
return mainCash
|
||||
}
|
||||
|
||||
return Number(walletInfo?.count_cash ?? walletInfo?.cash ?? 0)
|
||||
return Number(walletInfo?.cash ?? 0)
|
||||
}
|
||||
|
||||
export function getCashWalletBalance(
|
||||
walletInfo?: Record<string, unknown> | null,
|
||||
wallets?: UserWalletLine[] | null
|
||||
): number {
|
||||
return getRialCashAmount(walletInfo, wallets)
|
||||
}
|
||||
|
||||
export function getTotalRialEquivalent(walletInfo?: Record<string, unknown> | null): number {
|
||||
|
||||
@@ -7,6 +7,9 @@ export const UNIFIED_PRODUCT_CATEGORIES = [
|
||||
'گوی',
|
||||
'سکه',
|
||||
'سکه پارسیان',
|
||||
'شمش',
|
||||
'شمش ونوس',
|
||||
'شمش برنا',
|
||||
'مس',
|
||||
'نقره',
|
||||
] as const
|
||||
|
||||
+91
-17
@@ -2,6 +2,10 @@ import type { Product } from '@/models/products'
|
||||
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
|
||||
import { isMetalCommodityGroup, isMeltedProductUsingAppPrice, isMeltedTradeGroup } from '@/utils/productCategories'
|
||||
|
||||
function productUsesLastPriceIfZero(product: Product): boolean {
|
||||
return Boolean(product.use_last_price_if_zero)
|
||||
}
|
||||
|
||||
export function resolvePriceOffsetRatio(offsetPercent?: number | null) {
|
||||
const offset = Number(offsetPercent ?? 0)
|
||||
if (!offset) {
|
||||
@@ -35,44 +39,114 @@ export function applyChartPriceOffset(basePrice: number, offsetPercent?: number
|
||||
return Math.round(basePrice + basePrice * ratio)
|
||||
}
|
||||
|
||||
/** قیمت خرید نمایشی محصول (همان مقداری که در لیست و اکشنشیت نشان داده میشود) */
|
||||
export function getProductBuyPrice(product: Product): number {
|
||||
/** قیمت پایه بدون کارمزد — اگر صفر باشد نباید فقط cash_fee بهعنوان قیمت نمایش داده شود */
|
||||
export function getProductBaseBuyPrice(product: Product): number {
|
||||
if (isMeltedProductUsingAppPrice(product)) {
|
||||
const base = applyPriceOffsetPercent(getTradeBuyPrice(lastPrice.value), product.price_offset_percent)
|
||||
return base + (product.cash_fee_buy ?? 0)
|
||||
const apiPrice = Number(product.price ?? 0)
|
||||
const useLast = productUsesLastPriceIfZero(product)
|
||||
|
||||
if (!useLast) {
|
||||
const liveBuy = getTradeBuyPrice(lastPrice.value)
|
||||
if (liveBuy > 0) {
|
||||
return applyPriceOffsetPercent(liveBuy, product.price_offset_percent)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if (apiPrice > 0) {
|
||||
return apiPrice
|
||||
}
|
||||
|
||||
const liveBuy = getTradeBuyPrice(lastPrice.value)
|
||||
if (liveBuy > 0) {
|
||||
return applyPriceOffsetPercent(liveBuy, product.price_offset_percent)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
if (isMeltedTradeGroup(product.group_type)) {
|
||||
return (product.price ?? 0) + (product.cash_fee_buy ?? 0)
|
||||
return Number(product.price ?? 0)
|
||||
}
|
||||
|
||||
if (isMetalCommodityGroup(product.group_type)) {
|
||||
const base = (product.price_profit ?? 0) + (product.price ?? 0)
|
||||
return base + (product.cash_fee_buy ?? 0)
|
||||
return Number(product.price_profit ?? 0) + Number(product.price ?? 0)
|
||||
}
|
||||
|
||||
const basePrice = (product.price ?? 0) + (product.cash_fee_buy ?? 0)
|
||||
return product.direct_price === 0
|
||||
? basePrice * (product.amount ?? 0)
|
||||
: basePrice
|
||||
return Number(product.price ?? 0)
|
||||
}
|
||||
|
||||
/** قیمت فروش نمایشی محصول آبشدهای */
|
||||
export function getProductSellPrice(product: Product): number {
|
||||
/** قیمت خرید نمایشی محصول (همان مقداری که در لیست و اکشنشیت نشان داده میشود) */
|
||||
export function getProductBuyPrice(product: Product): number {
|
||||
const base = getProductBaseBuyPrice(product)
|
||||
if (base <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const fee = Number(product.cash_fee_buy ?? 0)
|
||||
|
||||
if (
|
||||
isMeltedProductUsingAppPrice(product)
|
||||
|| isMeltedTradeGroup(product.group_type)
|
||||
|| isMetalCommodityGroup(product.group_type)
|
||||
) {
|
||||
return base + fee
|
||||
}
|
||||
|
||||
const withFee = base + fee
|
||||
return product.direct_price === 0
|
||||
? withFee * Number(product.amount ?? 0)
|
||||
: withFee
|
||||
}
|
||||
|
||||
function getProductBaseSellPrice(product: Product): number {
|
||||
if (isMeltedProductUsingAppPrice(product)) {
|
||||
const base = applyPriceOffsetPercent(getTradeSellPrice(lastPrice.value), product.price_offset_percent)
|
||||
return base - (product.cash_fee_sell ?? 0)
|
||||
const apiPrice = Number(product.price ?? 0)
|
||||
const useLast = productUsesLastPriceIfZero(product)
|
||||
|
||||
if (!useLast) {
|
||||
const liveSell = getTradeSellPrice(lastPrice.value)
|
||||
if (liveSell > 0) {
|
||||
return applyPriceOffsetPercent(liveSell, product.price_offset_percent)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if (apiPrice > 0) {
|
||||
return apiPrice
|
||||
}
|
||||
|
||||
const liveSell = getTradeSellPrice(lastPrice.value)
|
||||
if (liveSell > 0) {
|
||||
return applyPriceOffsetPercent(liveSell, product.price_offset_percent)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
if (isMeltedTradeGroup(product.group_type)) {
|
||||
return (product.price ?? 0) - (product.cash_fee_sell ?? 0)
|
||||
return Number(product.price ?? 0)
|
||||
}
|
||||
|
||||
return getTradeSellPrice(lastPrice.value)
|
||||
}
|
||||
|
||||
/** قیمت فروش نمایشی محصول آبشدهای */
|
||||
export function getProductSellPrice(product: Product): number {
|
||||
const base = getProductBaseSellPrice(product)
|
||||
if (base <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (isMeltedProductUsingAppPrice(product) || isMeltedTradeGroup(product.group_type)) {
|
||||
return base - Number(product.cash_fee_sell ?? 0)
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
export function isProductPriceInactive(product: Product): boolean {
|
||||
return getProductBuyPrice(product) <= 0
|
||||
return getProductBaseBuyPrice(product) <= 0
|
||||
}
|
||||
|
||||
export { isMeltedTradeGroup, isMeltedProductUsingAppPrice } from '@/utils/productCategories'
|
||||
|
||||
@@ -20,10 +20,16 @@ export const supportMessengerSettings = ref<SupportMessengerSettings>({})
|
||||
export const DEFAULT_SUPPORT_MESSENGER_TEXT =
|
||||
'جهت شارژ حساب کاربری خود، کلمه "شماره حساب" را برای پشتیبانی ارسال نمایید.'
|
||||
|
||||
function getActiveSettingValue(settings: Array<{ key?: string; is_active?: boolean; value?: unknown }>, key: string) {
|
||||
const setting = settings.find((item) => item?.key === key && item?.is_active === true)
|
||||
const value = setting?.value
|
||||
function getSupportSettingValue(
|
||||
settings: Array<{ key?: string; is_active?: boolean | number; value?: unknown }>,
|
||||
key: string
|
||||
) {
|
||||
const setting = settings.find((item) => item?.key === key)
|
||||
if (!setting) return ''
|
||||
|
||||
const value = setting.value
|
||||
if (value === undefined || value === null) return ''
|
||||
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
@@ -33,7 +39,7 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin
|
||||
|
||||
if (/^https?:\/\//i.test(id)) return id
|
||||
|
||||
const type = (messenger ?? '').trim().toLowerCase()
|
||||
const type = (messenger ?? '').trim().toLowerCase() || 'bale'
|
||||
const username = id.replace(/^@/, '')
|
||||
|
||||
switch (type) {
|
||||
@@ -53,7 +59,7 @@ export function buildSupportMessengerUrl(messenger?: string, messengerId?: strin
|
||||
case 'soroush':
|
||||
return `https://splus.ir/splus/${username}`
|
||||
default:
|
||||
return ''
|
||||
return `https://ble.ir/${username}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +69,9 @@ export async function loadSupportMessengerFromSettings() {
|
||||
if (!settings || !Array.isArray(settings)) return
|
||||
|
||||
supportMessengerSettings.value = {
|
||||
messenger: getActiveSettingValue(settings, 'support_messenger'),
|
||||
messengerId: getActiveSettingValue(settings, 'support_messenger_id'),
|
||||
messengerText: getActiveSettingValue(settings, 'support_messenger_text'),
|
||||
messenger: getSupportSettingValue(settings, 'support_messenger') || 'bale',
|
||||
messengerId: getSupportSettingValue(settings, 'support_messenger_id'),
|
||||
messengerText: getSupportSettingValue(settings, 'support_messenger_text'),
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const DRAG_THRESHOLD = 6
|
||||
|
||||
type FabPosition = { x: number; y: number }
|
||||
|
||||
type DraggableFloatingOptions = {
|
||||
width?: number
|
||||
height?: number
|
||||
defaultBottomOffset?: number
|
||||
defaultRightOffset?: number
|
||||
}
|
||||
|
||||
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 pos = ref<FabPosition>({ x: 0, y: 0 })
|
||||
const dragging = ref(false)
|
||||
|
||||
let pointerId: number | null = null
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let originX = 0
|
||||
let originY = 0
|
||||
let hasMoved = false
|
||||
|
||||
const fabStyle = computed(() => ({
|
||||
left: `${pos.value.x}px`,
|
||||
top: `${pos.value.y}px`,
|
||||
width: `${width}px`,
|
||||
touchAction: 'none'
|
||||
}))
|
||||
|
||||
function getViewportWidth() {
|
||||
return Math.min(window.innerWidth, 426)
|
||||
}
|
||||
|
||||
function getHorizontalOffset() {
|
||||
const vw = window.innerWidth
|
||||
const contentWidth = getViewportWidth()
|
||||
return Math.max(0, (vw - contentWidth) / 2)
|
||||
}
|
||||
|
||||
function resetToDefault() {
|
||||
const vh = window.innerHeight
|
||||
const offsetX = getHorizontalOffset()
|
||||
const contentWidth = getViewportWidth()
|
||||
|
||||
pos.value = {
|
||||
x: offsetX + contentWidth - width - defaultRightOffset,
|
||||
y: vh - defaultBottomOffset - height
|
||||
}
|
||||
}
|
||||
|
||||
function clampToViewport() {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
|
||||
pos.value = {
|
||||
x: Math.max(0, Math.min(vw - width, pos.value.x)),
|
||||
y: Math.max(0, Math.min(vh - height, pos.value.y))
|
||||
}
|
||||
}
|
||||
|
||||
function loadPosition() {
|
||||
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()
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function savePosition() {
|
||||
localStorage.setItem(storageKey, JSON.stringify(pos.value))
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
clampToViewport()
|
||||
savePosition()
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
dragging.value = true
|
||||
hasMoved = false
|
||||
pointerId = event.pointerId
|
||||
startX = event.clientX
|
||||
startY = event.clientY
|
||||
originX = pos.value.x
|
||||
originY = pos.value.y
|
||||
;(event.currentTarget as HTMLElement)?.setPointerCapture?.(event.pointerId)
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!dragging.value || pointerId !== event.pointerId) return
|
||||
|
||||
const dx = event.clientX - startX
|
||||
const dy = event.clientY - startY
|
||||
|
||||
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
||||
hasMoved = true
|
||||
}
|
||||
|
||||
pos.value = {
|
||||
x: originX + dx,
|
||||
y: originY + dy
|
||||
}
|
||||
clampToViewport()
|
||||
}
|
||||
|
||||
function finishPointer(event: PointerEvent) {
|
||||
if (!dragging.value || pointerId !== event.pointerId) return false
|
||||
|
||||
dragging.value = false
|
||||
pointerId = null
|
||||
savePosition()
|
||||
|
||||
const clicked = !hasMoved
|
||||
hasMoved = false
|
||||
return clicked
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
return finishPointer(event)
|
||||
}
|
||||
|
||||
function handlePointerCancel(event: PointerEvent) {
|
||||
finishPointer(event)
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!loadPosition()) {
|
||||
resetToDefault()
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
return {
|
||||
dragging,
|
||||
fabStyle,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handlePointerCancel,
|
||||
resetToDefault
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user