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:
2026-06-14 10:24:43 +03:30
co-authored by Cursor
19 changed files with 523 additions and 97 deletions
+7
View File
@@ -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)
}
+4 -2
View File
@@ -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))
+17 -2
View File
@@ -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 {
+3
View File
@@ -7,6 +7,9 @@ export const UNIFIED_PRODUCT_CATEGORIES = [
'گوی',
'سکه',
'سکه پارسیان',
'شمش',
'شمش ونوس',
'شمش برنا',
'مس',
'نقره',
] as const
+91 -17
View File
@@ -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'
+14 -8
View File
@@ -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)
+165
View File
@@ -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
}
}